Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc21e1840 | ||
|
|
f087b41b4b | ||
|
|
6066890bae | ||
|
|
96c08ed15e | ||
|
|
52e87608ad | ||
|
|
90be279a2b | ||
|
|
eb39c1ed5d | ||
|
|
121c59760e | ||
|
|
9e2096c9b6 | ||
|
|
e63fe39578 | ||
|
|
b7d21f80df | ||
|
|
c7711c0ca2 | ||
|
|
319619d200 |
@@ -1,5 +1,102 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.23.1 — 2026-10-21
|
||||||
|
|
||||||
|
### Correctifs — barre commune et barre latérale (retours de Bruno)
|
||||||
|
|
||||||
|
- **Barre d'apps pleine largeur** : elle était repoussée à droite
|
||||||
|
de la barre latérale par le `padding-left` du cadre ; elle est
|
||||||
|
maintenant fixe, pleine largeur, et le cadre entier est décalé
|
||||||
|
de sa hauteur (60 px, 52 px en mobile).
|
||||||
|
- **Barre commune v0.1.2** (dracodev-commons) : palier mobile
|
||||||
|
resserré selon la spec — app courante, alertes, Spark, avatar ;
|
||||||
|
rail, recherche, domaines et menu disparaissent au profit des
|
||||||
|
contrôles natifs de chaque app.
|
||||||
|
- **Animation du repli de la barre latérale** : durée et courbe
|
||||||
|
unifiées (0,26 s, `cubic-bezier(0.4, 0, 0.2, 1)`) entre le corps
|
||||||
|
et la barre, et retrait du `backdrop-filter` (fond déjà opaque
|
||||||
|
à 96 %) qui rendait l'animation de largeur saccadée.
|
||||||
|
|
||||||
|
## 0.23.0 — 2026-10-21
|
||||||
|
|
||||||
|
### Fusion — Kanbante rejoint l'accès centralisé (cartes #27, #28)
|
||||||
|
|
||||||
|
- **Barre d'apps commune dracodev** (ACC-1, #28) : snippet figé
|
||||||
|
vendangé de dracodev-commons v0.1.1 (partiel `_draco_bar.html`,
|
||||||
|
`draco-barre.css` / `draco-barre.js`), inclus par le cadre partagé
|
||||||
|
— Kanbante était la dernière des quatre applications sans la barre.
|
||||||
|
Le rail pilote le repli natif de la barre latérale, la palette
|
||||||
|
ouvre la recherche & commandes native ; alertes unifiées et Spark
|
||||||
|
annoncent leur arrivée avec la fédération.
|
||||||
|
- **Pont SSO `draco_session`** (ACC-3.2, #27) : le cookie émis par
|
||||||
|
ObsiGate est vérifié localement (`app/draco.py` — JWS HS256 au
|
||||||
|
secret partagé `DRACO_SESSION_SECRET`, révocation de l'émettrice
|
||||||
|
en cache 60 s) et converti en session Kanbante native par
|
||||||
|
`GET /auth/draco` ; bouton « Se connecter avec ObsiGate (SSO) »
|
||||||
|
sur l'écran de connexion. Kanbante étant mono-utilisateur, aucun
|
||||||
|
mapping de comptes n'est nécessaire. Inerte sans le secret.
|
||||||
|
- Tests : `test_draco_sso.py` (7) et `test_draco_barre.py` (3) ;
|
||||||
|
suite complète : 211 tests verts.
|
||||||
|
|
||||||
|
## 0.22.1 — 2026-10-20
|
||||||
|
|
||||||
|
### Correctif — colonne Terminé vide
|
||||||
|
|
||||||
|
- Les issues **fermées dans Gitea** mais suivies par le board
|
||||||
|
n'apparaissaient pas dans la colonne **Terminé** : la
|
||||||
|
réconciliation plaçait bien leur ligne locale en `done`, mais
|
||||||
|
l'affichage du board ne fusionnait que les issues **ouvertes**.
|
||||||
|
- Le board (page HTML et API JSON) inclut désormais les issues
|
||||||
|
fermées dont la ligne locale est en `done`, y compris dans la
|
||||||
|
vue par milestone.
|
||||||
|
- Test de régression ajouté ; suite complète : 201 tests verts.
|
||||||
|
|
||||||
|
## 0.22.0 — 2026-10-14
|
||||||
|
|
||||||
|
### Écran de connexion (demande de Bruno)
|
||||||
|
|
||||||
|
- Fini la fenêtre Basic Auth du navigateur : page **/login**
|
||||||
|
professionnelle (carte centrée, marque dégradée, erreur
|
||||||
|
claire, retour à la page demandée après connexion).
|
||||||
|
- Session par **cookie signé HMAC** (7 jours, HttpOnly,
|
||||||
|
SameSite=Lax) ; clé `KANBANTE_SESSION_SECRET` ou dérivée des
|
||||||
|
identifiants — changer le mot de passe invalide les sessions.
|
||||||
|
- L'en-tête **Basic reste accepté** pour les scripts ; les API
|
||||||
|
reçoivent 401, seuls les navigateurs sont redirigés. Lien
|
||||||
|
**⏻ Sortir** dans le pied de la barre latérale, `/logout`
|
||||||
|
réel. Protection CSRF sur le formulaire, redirection `next`
|
||||||
|
limitée aux chemins locaux.
|
||||||
|
|
||||||
|
### Barre latérale (demandes de Bruno)
|
||||||
|
|
||||||
|
- Le bouton **Replier** quitte le bas de la barre : c'est une
|
||||||
|
**icône seule, en haut, à droite du bouton recherche**.
|
||||||
|
- **Accordéon exclusif** : ouvrir une section ferme les
|
||||||
|
autres — une seule ouverte à la fois (état mémorisé dans
|
||||||
|
`kanbante-nav-open` ; la section de la page courante prime).
|
||||||
|
|
||||||
|
### Chantier B — instantanés d'arrière-plan (validé par Bruno)
|
||||||
|
|
||||||
|
- Un **rafraîchisseur** recalcule périodiquement les listes
|
||||||
|
coûteuses (index des dépôts, étoiles, issues `all` **et**
|
||||||
|
`open`, jalons — pour chaque projet actif) et les persiste
|
||||||
|
dans SQLite (table `snapshots`, écriture à chaque réponse du
|
||||||
|
client). `KANBANTE_SNAPSHOT_INTERVAL` (défaut 120 s, 0 = off).
|
||||||
|
- Au démarrage, le cache du client est **hydraté** depuis les
|
||||||
|
instantanés (servis périmés + rafraîchis en tâche de fond,
|
||||||
|
mécanique SWR) : la première page après un redémarrage ou un
|
||||||
|
redéploiement ne paie plus Gitea.
|
||||||
|
- Un **webhook Gitea** rafraîchit immédiatement les
|
||||||
|
instantanés du dépôt touché. La fraîcheur est affichée dans
|
||||||
|
le pied de la barre (« 🕐 à l'instant / il y a N min ») :
|
||||||
|
les données Gitea peuvent dater d'un intervalle au plus,
|
||||||
|
le journal local reste instantané.
|
||||||
|
- **Mesuré sur l'instance réelle** (via le proxy de Spark,
|
||||||
|
latences gonflées) : premier chargement à froid 9,4 s sans
|
||||||
|
instantanés → après redémarrage avec instantanés : Projets
|
||||||
|
0,31 s, Focus 0,16 s, Triage 0,07 s, Santé 0,22 s,
|
||||||
|
Prévisions 0,08 s.
|
||||||
|
|
||||||
## 0.21.0 — 2026-10-12
|
## 0.21.0 — 2026-10-12
|
||||||
|
|
||||||
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
|
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ sudo systemctl enable --now docker
|
|||||||
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
|
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
|
||||||
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
|
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
|
||||||
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
|
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
|
||||||
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
|
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
|
||||||
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
|
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
|
||||||
|
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
|
||||||
|
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
|
||||||
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
|
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
|
||||||
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
||||||
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
||||||
|
|||||||
+30
-3
@@ -8,9 +8,24 @@ Environment variables:
|
|||||||
GITEA_URL Base URL of the Gitea instance.
|
GITEA_URL Base URL of the Gitea instance.
|
||||||
GITEA_TOKEN Gitea API token (read/write issues).
|
GITEA_TOKEN Gitea API token (read/write issues).
|
||||||
KANBANTE_DB_PATH Path of the local SQLite database.
|
KANBANTE_DB_PATH Path of the local SQLite database.
|
||||||
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
|
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
|
||||||
KANBANTE_AUTH_PASSWORD Basic-auth password.
|
KANBANTE_AUTH_PASSWORD Login password.
|
||||||
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
|
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
|
||||||
|
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
|
||||||
|
(default: derived from the credentials —
|
||||||
|
rotating the password invalidates sessions).
|
||||||
|
DRACO_SESSION_SECRET Shared secret of the dracodev SSO cookie
|
||||||
|
(``draco_session``, emitted by ObsiGate —
|
||||||
|
Fusion ACC-3). Empty (default) = SSO bridge
|
||||||
|
disabled. ``DRACO_SESSION_SECRET_PREV``
|
||||||
|
accepts the previous secret while rotating,
|
||||||
|
``DRACO_SSO_MACHINE_KEY`` authenticates the
|
||||||
|
revocation-list fetch and ``DRACO_SSO_URL``
|
||||||
|
points at the emitter (default
|
||||||
|
https://og.dracodev.net).
|
||||||
|
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
|
||||||
|
refreshes (chantier B, default 120;
|
||||||
|
0 disables the refresher).
|
||||||
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
|
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
|
||||||
days counts as "active" (default 365).
|
days counts as "active" (default 365).
|
||||||
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
|
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
|
||||||
@@ -89,7 +104,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.21.0"
|
APP_VERSION = "0.23.1"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -158,6 +173,12 @@ class Settings:
|
|||||||
briefing_include_standup: bool = False
|
briefing_include_standup: bool = False
|
||||||
rules_raw: str = ""
|
rules_raw: str = ""
|
||||||
zombie_days: int = 30
|
zombie_days: int = 30
|
||||||
|
session_secret: str = ""
|
||||||
|
snapshot_interval: int = 120
|
||||||
|
draco_secret: str = ""
|
||||||
|
draco_secret_prev: str = ""
|
||||||
|
sso_machine_key: str = ""
|
||||||
|
sso_url: str = "https://og.dracodev.net"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -203,4 +224,10 @@ def get_settings() -> Settings:
|
|||||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||||
|
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
|
||||||
|
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
|
||||||
|
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
|
||||||
|
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
|
||||||
|
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
||||||
|
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
|||||||
{
|
{
|
||||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
"capabilities": {},
|
"capabilities": {},
|
||||||
"clientInfo": {"name": "kanbante", "version": "0.21.0"},
|
"clientInfo": {"name": "kanbante", "version": "0.22.1"},
|
||||||
},
|
},
|
||||||
req_id=1,
|
req_id=1,
|
||||||
)
|
)
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
"""Validation locale du cookie SSO ``draco_session`` — Fusion ACC-3.2.
|
||||||
|
|
||||||
|
Kanbante valide localement le jeton émis par ObsiGate (l'émettrice,
|
||||||
|
ACC-3.1) : JWS HS256 vérifié avec le secret partagé
|
||||||
|
(``DRACO_SESSION_SECRET``, ``_PREV`` accepté en rotation),
|
||||||
|
algorithme épinglé, expiration, et liste de révocation de
|
||||||
|
l'émettrice (cache 60 s, interrogée seulement si un cookie draco
|
||||||
|
est présenté). Vérification hmac/hashlib — mêmes sémantiques que
|
||||||
|
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
|
||||||
|
|
||||||
|
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
|
||||||
|
en session Kanbante native. Kanbante est mono-utilisateur
|
||||||
|
(identifiants configurés par l'environnement) : un jeton valide
|
||||||
|
désigne l'opérateur configuré — pas de table de liens. Sans secret
|
||||||
|
configuré, module inerte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COOKIE_NAME = "draco_session"
|
||||||
|
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
|
||||||
|
|
||||||
|
_revoked_cache: dict[str, int] = {}
|
||||||
|
_revoked_fetched_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(get_settings().draco_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64decode(segment: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> dict | None:
|
||||||
|
"""Vérifie un JWS HS256 (signature, alg épinglé, expiration)."""
|
||||||
|
settings = get_settings()
|
||||||
|
if not token or not settings.draco_secret:
|
||||||
|
return None
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
header = json.loads(_b64decode(parts[0]))
|
||||||
|
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
||||||
|
return None
|
||||||
|
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
|
||||||
|
signature = _b64decode(parts[2])
|
||||||
|
claims = json.loads(_b64decode(parts[1]))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(claims, dict):
|
||||||
|
return None
|
||||||
|
for secret in (settings.draco_secret, settings.draco_secret_prev):
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
attendu = hmac.new(secret.encode("utf-8"), signing_input,
|
||||||
|
hashlib.sha256).digest()
|
||||||
|
if hmac.compare_digest(attendu, signature):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if int(claims.get("exp", 0)) <= int(time.time()):
|
||||||
|
return None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
# ── Révocations (cache 60 s alimenté par l'émettrice) ───────────────────
|
||||||
|
|
||||||
|
def _refresh_revocations() -> None:
|
||||||
|
global _revoked_fetched_at
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _revoked_fetched_at < _REVOCATIONS_TTL:
|
||||||
|
return
|
||||||
|
_revoked_fetched_at = now
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if settings.sso_machine_key:
|
||||||
|
headers["X-Draco-Machine-Key"] = settings.sso_machine_key
|
||||||
|
r = httpx.get(f"{settings.sso_url}/api/sso/revocations",
|
||||||
|
headers=headers, timeout=2.0)
|
||||||
|
if r.status_code == 200:
|
||||||
|
_revoked_cache.clear()
|
||||||
|
for item in r.json().get("revoked", []):
|
||||||
|
_revoked_cache[str(item["jti"])] = int(item["exp"])
|
||||||
|
except Exception as exc: # l'émettrice injoignable ne bloque pas le SSO
|
||||||
|
logger.debug("Révocations draco indisponibles: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_revoked(jti: str) -> bool:
|
||||||
|
_refresh_revocations()
|
||||||
|
return jti in _revoked_cache
|
||||||
|
|
||||||
|
|
||||||
|
def claims_for(request) -> dict | None:
|
||||||
|
"""Claims valides du cookie draco de la requête (révocation incluse)."""
|
||||||
|
if not enabled():
|
||||||
|
return None
|
||||||
|
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
|
||||||
|
if not claims:
|
||||||
|
return None
|
||||||
|
jti = str(claims.get("jti", ""))
|
||||||
|
if jti and is_revoked(jti):
|
||||||
|
return None
|
||||||
|
if not claims.get("sub"):
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
+104
-13
@@ -64,6 +64,10 @@ class GiteaClient:
|
|||||||
self._cache: dict[str, tuple[float, Any]] = {}
|
self._cache: dict[str, tuple[float, Any]] = {}
|
||||||
self._refreshing: set[str] = set()
|
self._refreshing: set[str] = set()
|
||||||
self._cache_gen = 0
|
self._cache_gen = 0
|
||||||
|
# Snapshot bookkeeping (chantier B): cache key -> UTC time
|
||||||
|
# the payload was fetched from Gitea, for the freshness
|
||||||
|
# label and the persisted `snapshots` table.
|
||||||
|
self._snapshot_times: dict[str, str] = {}
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
@@ -112,6 +116,90 @@ class GiteaClient:
|
|||||||
def _cache_put(self, key: str, value: Any) -> None:
|
def _cache_put(self, key: str, value: Any) -> None:
|
||||||
if self.cache_ttl > 0:
|
if self.cache_ttl > 0:
|
||||||
self._cache[key] = (time.monotonic(), value)
|
self._cache[key] = (time.monotonic(), value)
|
||||||
|
self._persist_snapshot(key, value)
|
||||||
|
|
||||||
|
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
||||||
|
|
||||||
|
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||||
|
"""Write-through to the SQLite snapshots table.
|
||||||
|
|
||||||
|
Persistence must never break a read: any failure (DB
|
||||||
|
locked, unserializable payload) is logged and swallowed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
models.save_snapshot(key, value)
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
self._snapshot_times[key] = datetime.datetime.now(
|
||||||
|
datetime.timezone.utc
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("snapshot persist failed for %s: %s", key, exc)
|
||||||
|
|
||||||
|
def hydrate_from_snapshots(self) -> int:
|
||||||
|
"""Preload the cache from persisted snapshots (startup).
|
||||||
|
|
||||||
|
Entries are inserted with an already-stale timestamp: the
|
||||||
|
SWR layer serves them instantly AND schedules a background
|
||||||
|
refresh, so the first page after a restart renders from
|
||||||
|
local data (tens of ms) while Gitea data catches up.
|
||||||
|
Returns the number of entries hydrated.
|
||||||
|
"""
|
||||||
|
if self.cache_ttl <= 0:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
snapshots = models.load_snapshots()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("snapshot hydration failed: %s", exc)
|
||||||
|
return 0
|
||||||
|
stale_at = time.monotonic() - self.cache_ttl - 1.0
|
||||||
|
for key, (payload, updated_at) in snapshots.items():
|
||||||
|
self._cache[key] = (stale_at, payload)
|
||||||
|
self._snapshot_times[key] = updated_at
|
||||||
|
if snapshots:
|
||||||
|
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
||||||
|
return len(snapshots)
|
||||||
|
|
||||||
|
def latest_snapshot_at(self) -> str | None:
|
||||||
|
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||||
|
if not self._snapshot_times:
|
||||||
|
return None
|
||||||
|
return max(self._snapshot_times.values())
|
||||||
|
|
||||||
|
async def _force(self, key: str, fetcher: Callable[[], Awaitable[Any]]) -> Any:
|
||||||
|
"""Fetch now, bypassing freshness (background refresher)."""
|
||||||
|
value = await fetcher()
|
||||||
|
self._cache_put(key, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def refresh_starred(self) -> list[dict]:
|
||||||
|
return await self._force(
|
||||||
|
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||||
|
)
|
||||||
|
|
||||||
|
async def refresh_repo_issues(
|
||||||
|
self, owner: str, repo: str, state: str = "all"
|
||||||
|
) -> list[dict]:
|
||||||
|
return await self._force(
|
||||||
|
f"issues:{owner}/{repo}:{state}",
|
||||||
|
lambda: self._get_all_pages(
|
||||||
|
f"/repos/{owner}/{repo}/issues", state=state
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def refresh_milestones(
|
||||||
|
self, owner: str, repo: str
|
||||||
|
) -> list[dict]:
|
||||||
|
return await self._force(
|
||||||
|
f"milestones:{owner}/{repo}:open",
|
||||||
|
lambda: self._get_all_pages(
|
||||||
|
f"/repos/{owner}/{repo}/milestones", state="open"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _cache_clear(self) -> None:
|
def _cache_clear(self) -> None:
|
||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
@@ -182,22 +270,25 @@ class GiteaClient:
|
|||||||
self._user = await self._get("/user")
|
self._user = await self._get("/user")
|
||||||
return self._user
|
return self._user
|
||||||
|
|
||||||
|
async def _fetch_search_repos(self) -> list[dict]:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
user = await self.get_user()
|
||||||
|
if user and user.get("id"):
|
||||||
|
params["uid"] = user["id"]
|
||||||
|
except Exception as exc: # no/invalid token → public search
|
||||||
|
logger.debug(
|
||||||
|
"get_user failed, falling back to public search: %s", exc
|
||||||
|
)
|
||||||
|
return await self._get_all_pages("/repos/search", **params)
|
||||||
|
|
||||||
async def search_repos(self) -> list[dict]:
|
async def search_repos(self) -> list[dict]:
|
||||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||||
|
return await self._cached_list("search_repos", self._fetch_search_repos)
|
||||||
|
|
||||||
async def _fetch() -> list[dict]:
|
async def refresh_search_repos(self) -> list[dict]:
|
||||||
params: dict[str, Any] = {}
|
"""Force-fetch the repo index (background refresher)."""
|
||||||
try:
|
return await self._force("search_repos", self._fetch_search_repos)
|
||||||
user = await self.get_user()
|
|
||||||
if user and user.get("id"):
|
|
||||||
params["uid"] = user["id"]
|
|
||||||
except Exception as exc: # no/invalid token → public search
|
|
||||||
logger.debug(
|
|
||||||
"get_user failed, falling back to public search: %s", exc
|
|
||||||
)
|
|
||||||
return await self._get_all_pages("/repos/search", **params)
|
|
||||||
|
|
||||||
return await self._cached_list("search_repos", _fetch)
|
|
||||||
|
|
||||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||||
return await self._get(f"/repos/{owner}/{repo}")
|
return await self._get(f"/repos/{owner}/{repo}")
|
||||||
|
|||||||
+223
-33
@@ -22,13 +22,21 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
from fastapi.responses import (
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
Response,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -37,6 +45,7 @@ from app.config import APP_VERSION, get_settings
|
|||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
from app import code_links as code_links_service
|
from app import code_links as code_links_service
|
||||||
from app import digest as digest_service
|
from app import digest as digest_service
|
||||||
|
from app import snapshots as snapshots_service
|
||||||
from app import flow as flow_service
|
from app import flow as flow_service
|
||||||
from app import forecast as forecast_service
|
from app import forecast as forecast_service
|
||||||
from app import rules as rules_service
|
from app import rules as rules_service
|
||||||
@@ -155,6 +164,13 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||||
)
|
)
|
||||||
|
# Chantier B: hydrate the client cache from persisted
|
||||||
|
# snapshots BEFORE serving, then keep them warm in the
|
||||||
|
# background — pages never wait on Gitea when data exists.
|
||||||
|
gitea.hydrate_from_snapshots()
|
||||||
|
snapshot_task = asyncio.create_task(
|
||||||
|
snapshots_service.snapshot_loop(gitea, classify_repos)
|
||||||
|
)
|
||||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
digest_task = asyncio.create_task(_digest_scheduler())
|
digest_task = asyncio.create_task(_digest_scheduler())
|
||||||
rules_task = asyncio.create_task(_rules_scheduler())
|
rules_task = asyncio.create_task(_rules_scheduler())
|
||||||
@@ -179,6 +195,7 @@ async def lifespan(app: FastAPI):
|
|||||||
settings.capture_ntfy_topic,
|
settings.capture_ntfy_topic,
|
||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
|
snapshot_task.cancel()
|
||||||
scheduler.cancel()
|
scheduler.cancel()
|
||||||
digest_task.cancel()
|
digest_task.cancel()
|
||||||
rules_task.cancel()
|
rules_task.cancel()
|
||||||
@@ -192,20 +209,82 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
|||||||
gitea = GiteaClient()
|
gitea = GiteaClient()
|
||||||
|
|
||||||
|
|
||||||
# ── Auth middleware ────────────────────────────────────────
|
# ── Auth: login form + signed session cookie (0.22.0) ──────
|
||||||
|
# Before 0.22.0 the browser got HTTP Basic's native prompt.
|
||||||
|
# Browsers now use a real login page; the session rides in an
|
||||||
|
# HMAC-signed cookie. An Authorization: Basic header is still
|
||||||
|
# honoured for scripts and API clients. /api/health stays
|
||||||
|
# public for Docker's HEALTHCHECK; /webhooks/gitea is
|
||||||
|
# authenticated by its HMAC signature (Gitea cannot log in) and
|
||||||
|
# /api/capture by its dedicated token.
|
||||||
|
|
||||||
|
SESSION_COOKIE = "kanbante_session"
|
||||||
|
SESSION_DAYS = 7
|
||||||
|
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login",
|
||||||
|
"/auth/draco")
|
||||||
|
|
||||||
|
|
||||||
|
def _session_secret() -> bytes:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.session_secret:
|
||||||
|
return settings.session_secret.encode("utf-8")
|
||||||
|
# Derived from the credentials: rotating the password
|
||||||
|
# invalidates every outstanding session (documented trade-off).
|
||||||
|
material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}"
|
||||||
|
return hashlib.sha256(material.encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(user: str) -> str:
|
||||||
|
exp = int(time.time()) + SESSION_DAYS * 86400
|
||||||
|
payload = f"{user}.{exp}"
|
||||||
|
sig = hmac.new(
|
||||||
|
_session_secret(), payload.encode("utf-8"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return f"{payload}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def _session_user(request: Request) -> str | None:
|
||||||
|
raw = request.cookies.get(SESSION_COOKIE, "")
|
||||||
|
user, _, rest = raw.partition(".")
|
||||||
|
exp, _, sig = rest.partition(".")
|
||||||
|
if not user or not exp or not sig:
|
||||||
|
return None
|
||||||
|
expected = hmac.new(
|
||||||
|
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if not secrets.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not secrets.compare_digest(user, get_settings().auth_user):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_ok(request: Request) -> bool:
|
||||||
|
settings = get_settings()
|
||||||
|
header = request.headers.get("authorization", "")
|
||||||
|
if not header.lower().startswith("basic "):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
user, _, password = decoded.partition(":")
|
||||||
|
return secrets.compare_digest(
|
||||||
|
user, settings.auth_user
|
||||||
|
) and secrets.compare_digest(password, settings.auth_password)
|
||||||
|
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
"""HTTP Basic auth, enabled only when credentials are configured.
|
"""Session cookie (browser) or Basic header (scripts)."""
|
||||||
|
|
||||||
/api/health stays public so Docker's HEALTHCHECK (liveness) works.
|
|
||||||
When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every
|
|
||||||
other route answers 503: failing closed beats failing open.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
path = request.url.path
|
||||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
if path in _PUBLIC_PATHS or path.startswith("/static/"):
|
||||||
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.auth_enabled:
|
if not settings.auth_enabled:
|
||||||
@@ -220,30 +299,108 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
)
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
if _session_user(request) is not None or _basic_ok(request):
|
||||||
ok = False
|
return await call_next(request)
|
||||||
header = request.headers.get("authorization", "")
|
# API consumers get a plain 401 (no browser prompt, no
|
||||||
if header.lower().startswith("basic "):
|
# redirect they cannot follow); browsers get the login page.
|
||||||
try:
|
if path.startswith("/api/") or "text/html" not in request.headers.get(
|
||||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
"accept", ""
|
||||||
except Exception:
|
):
|
||||||
decoded = ""
|
return JSONResponse(
|
||||||
user, _, password = decoded.partition(":")
|
{"ok": False, "error": "Authentication required"},
|
||||||
ok = secrets.compare_digest(
|
|
||||||
user, settings.auth_user
|
|
||||||
) and secrets.compare_digest(password, settings.auth_password)
|
|
||||||
if not ok:
|
|
||||||
return Response(
|
|
||||||
"Authentication required",
|
|
||||||
status_code=401,
|
status_code=401,
|
||||||
headers={"WWW-Authenticate": 'Basic realm="Kanbante"'},
|
|
||||||
)
|
)
|
||||||
return await call_next(request)
|
target = path
|
||||||
|
if request.url.query:
|
||||||
|
target = f"{path}?{request.url.query}"
|
||||||
|
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(AuthMiddleware)
|
app.add_middleware(AuthMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(raw: str) -> str:
|
||||||
|
"""Only local paths may be a post-login destination."""
|
||||||
|
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
|
||||||
|
return raw
|
||||||
|
return "/"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login_page(request: Request, next: str = "/"):
|
||||||
|
if not get_settings().auth_enabled or _session_user(request) is not None:
|
||||||
|
return RedirectResponse(_safe_next(next), status_code=303)
|
||||||
|
return render(request, "login.html", next=_safe_next(next), error="")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
async def login_submit(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(""),
|
||||||
|
password: str = Form(""),
|
||||||
|
next: str = Form("/"),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
return RedirectResponse("/", status_code=303)
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return render(
|
||||||
|
request, "login.html", status_code=403,
|
||||||
|
next=_safe_next(next), error="Session expirée — réessaie.",
|
||||||
|
)
|
||||||
|
ok = secrets.compare_digest(
|
||||||
|
username, settings.auth_user
|
||||||
|
) and secrets.compare_digest(password, settings.auth_password)
|
||||||
|
if not ok:
|
||||||
|
return render(
|
||||||
|
request, "login.html", status_code=401,
|
||||||
|
next=_safe_next(next), error="Identifiants incorrects.",
|
||||||
|
)
|
||||||
|
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
_make_session(username),
|
||||||
|
max_age=SESSION_DAYS * 86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
response = RedirectResponse("/login", status_code=303)
|
||||||
|
response.delete_cookie(SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/draco")
|
||||||
|
def draco_bridge(request: Request, next: str = "/"):
|
||||||
|
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
|
||||||
|
|
||||||
|
Point de retour de ``GET /sso/authorize`` de l'émettrice
|
||||||
|
(ObsiGate) : si le cookie partagé est valide, la session native
|
||||||
|
de l'opérateur configuré est créée (Kanbante est mono-utilisateur,
|
||||||
|
aucun mapping de comptes n'est nécessaire) ; sinon, retour à
|
||||||
|
l'écran de connexion. Inerte sans ``DRACO_SESSION_SECRET``.
|
||||||
|
"""
|
||||||
|
from app import draco
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled or draco.claims_for(request) is None:
|
||||||
|
return RedirectResponse("/login", status_code=303)
|
||||||
|
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
_make_session(settings.auth_user),
|
||||||
|
max_age=SESSION_DAYS * 86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# ── Live updates (SSE hub) ─────────────────────────────────
|
# ── Live updates (SSE hub) ─────────────────────────────────
|
||||||
|
|
||||||
class LiveHub:
|
class LiveHub:
|
||||||
@@ -329,7 +486,13 @@ def render(
|
|||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
context.setdefault("csrf_token", token)
|
context.setdefault("csrf_token", token)
|
||||||
context.setdefault("version", APP_VERSION)
|
context.setdefault("version", APP_VERSION)
|
||||||
|
# Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune.
|
||||||
|
context.setdefault(
|
||||||
|
"draco_avatar", (get_settings().auth_user or "K")[:1].upper()
|
||||||
|
)
|
||||||
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
||||||
|
context.setdefault("auth_enabled", get_settings().auth_enabled)
|
||||||
|
context.setdefault("data_freshness", snapshots_service.freshness_label())
|
||||||
html = _tpl_env.get_template(name).render(**context)
|
html = _tpl_env.get_template(name).render(**context)
|
||||||
response = HTMLResponse(html, status_code=status_code)
|
response = HTMLResponse(html, status_code=status_code)
|
||||||
if generated:
|
if generated:
|
||||||
@@ -700,13 +863,25 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
|||||||
# A kanban:* label changed outside Kanbante wins over local state.
|
# A kanban:* label changed outside Kanbante wins over local state.
|
||||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||||
|
|
||||||
|
# Closed issues stay visible while the board tracks them as done
|
||||||
|
# (reconcile_board_state moved their row). Merging open issues only
|
||||||
|
# made the "Terminé" column permanently empty.
|
||||||
|
done_numbers = {
|
||||||
|
c["issue_number"] for c in get_board_state(full_name).get("done", [])
|
||||||
|
}
|
||||||
|
closed_display = [
|
||||||
|
i
|
||||||
|
for i in issues
|
||||||
|
if i.get("state") != "open" and i["number"] in done_numbers
|
||||||
|
]
|
||||||
|
|
||||||
# Milestone view: filter the board to one milestone (sprint-style),
|
# Milestone view: filter the board to one milestone (sprint-style),
|
||||||
# with its deadline and completion progress.
|
# with its deadline and completion progress.
|
||||||
milestones: list[dict] = []
|
milestones: list[dict] = []
|
||||||
selected_milestone = None
|
selected_milestone = None
|
||||||
milestone_progress = None
|
milestone_progress = None
|
||||||
milestone_forecast = None
|
milestone_forecast = None
|
||||||
display_issues = open_issues
|
display_issues = open_issues + closed_display
|
||||||
try:
|
try:
|
||||||
milestones = await gitea.get_milestones(owner, repo)
|
milestones = await gitea.get_milestones(owner, repo)
|
||||||
except Exception as exc: # milestones are decoration, never fatal
|
except Exception as exc: # milestones are decoration, never fatal
|
||||||
@@ -719,7 +894,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
|||||||
str(ms.get("id")) == milestone or ms.get("title") == milestone
|
str(ms.get("id")) == milestone or ms.get("title") == milestone
|
||||||
)
|
)
|
||||||
|
|
||||||
display_issues = [i for i in open_issues if _in_milestone(i)]
|
display_issues = [i for i in display_issues if _in_milestone(i)]
|
||||||
selected_milestone = next(
|
selected_milestone = next(
|
||||||
(
|
(
|
||||||
m for m in milestones
|
m for m in milestones
|
||||||
@@ -809,7 +984,16 @@ def _board_columns_for(
|
|||||||
for number in changes["closed_numbers"]:
|
for number in changes["closed_numbers"]:
|
||||||
log_event(full_name, number, "closed", to_column="done", source="gitea")
|
log_event(full_name, number, "closed", to_column="done", source="gitea")
|
||||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||||
display_issues = open_issues
|
board_state = get_board_state(full_name)
|
||||||
|
# Closed issues stay visible while the board tracks them as done
|
||||||
|
# (reconcile_board_state moved their row). Merging open issues only
|
||||||
|
# made the "Terminé" column permanently empty.
|
||||||
|
done_numbers = {c["issue_number"] for c in board_state.get("done", [])}
|
||||||
|
display_issues = open_issues + [
|
||||||
|
i
|
||||||
|
for i in issues
|
||||||
|
if i.get("state") != "open" and i["number"] in done_numbers
|
||||||
|
]
|
||||||
if milestone:
|
if milestone:
|
||||||
def _in_ms(issue: dict) -> bool:
|
def _in_ms(issue: dict) -> bool:
|
||||||
ms = issue.get("milestone") or {}
|
ms = issue.get("milestone") or {}
|
||||||
@@ -817,8 +1001,7 @@ def _board_columns_for(
|
|||||||
str(ms.get("id")) == milestone or ms.get("title") == milestone
|
str(ms.get("id")) == milestone or ms.get("title") == milestone
|
||||||
)
|
)
|
||||||
|
|
||||||
display_issues = [i for i in open_issues if _in_ms(i)]
|
display_issues = [i for i in display_issues if _in_ms(i)]
|
||||||
board_state = get_board_state(full_name)
|
|
||||||
columns = _merge_issues_with_board(display_issues, board_state)
|
columns = _merge_issues_with_board(display_issues, board_state)
|
||||||
return columns, display_issues, board_state
|
return columns, display_issues, board_state
|
||||||
|
|
||||||
@@ -2303,6 +2486,13 @@ async def gitea_webhook(request: Request):
|
|||||||
handled = "pr-merged:" + (",".join(results) if results else "none")
|
handled = "pr-merged:" + (",".join(results) if results else "none")
|
||||||
|
|
||||||
gitea.invalidate_cache()
|
gitea.invalidate_cache()
|
||||||
|
# Chantier B: a webhook means this repo just changed — refresh
|
||||||
|
# its snapshots right away instead of waiting for the interval.
|
||||||
|
if full_name and "/" in full_name:
|
||||||
|
_owner, _, _repo = full_name.partition("/")
|
||||||
|
asyncio.create_task(
|
||||||
|
snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo)
|
||||||
|
)
|
||||||
await hub.broadcast(
|
await hub.broadcast(
|
||||||
{"repo": full_name, "event": event, "action": action, "issue": number}
|
{"repo": full_name, "event": event, "action": action, "issue": number}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ and upgrades older databases in place (position column, notes
|
|||||||
updated_at, cleanup of rows carrying an unknown column name).
|
updated_at, cleanup of rows carrying an unknown column name).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -91,6 +92,12 @@ def init_db() -> None:
|
|||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_repo
|
CREATE INDEX IF NOT EXISTS idx_events_repo
|
||||||
ON board_events(repo_full_name, issue_number, created_at);
|
ON board_events(repo_full_name, issue_number, created_at);
|
||||||
"""
|
"""
|
||||||
@@ -292,6 +299,48 @@ def set_kv(key: str, value: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Snapshots (chantier B, 0.22.0) ─────────────────────────
|
||||||
|
# Persisted copies of the Gitea list responses the pages read
|
||||||
|
# (repos, issues, milestones, starred). The client cache is
|
||||||
|
# hydrated from these rows at startup, so even the first page
|
||||||
|
# after a restart renders from local data while the background
|
||||||
|
# refresher re-fetches from Gitea.
|
||||||
|
|
||||||
|
def save_snapshot(key: str, payload) -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO snapshots (key, payload, updated_at) "
|
||||||
|
"VALUES (?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
|
||||||
|
"updated_at = excluded.updated_at",
|
||||||
|
(key, json.dumps(payload)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_snapshots() -> dict:
|
||||||
|
"""All snapshots as {key: (payload, updated_at_utc_str)}."""
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT key, payload, updated_at FROM snapshots"
|
||||||
|
).fetchall()
|
||||||
|
out = {}
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
out[row["key"]] = (json.loads(row["payload"]), row["updated_at"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def latest_snapshot_at() -> str | None:
|
||||||
|
"""UTC timestamp (SQLite format) of the freshest snapshot."""
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(updated_at) AS latest FROM snapshots"
|
||||||
|
).fetchone()
|
||||||
|
return row["latest"] if row else None
|
||||||
|
|
||||||
|
|
||||||
# ── Event journal ──────────────────────────────────────────
|
# ── Event journal ──────────────────────────────────────────
|
||||||
|
|
||||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Background snapshot refresher — chantier B (0.22.0).
|
||||||
|
|
||||||
|
The expensive part of Kanbante is the fan-out of Gitea list
|
||||||
|
calls behind the portfolio pages (repo index, then issues and
|
||||||
|
milestones for every active project). Since 0.19.0 those calls
|
||||||
|
are parallel and stale-while-revalidate, which fixed repeat
|
||||||
|
loads, but the FIRST page after a restart still paid seconds.
|
||||||
|
|
||||||
|
The refresher pays that cost instead of the user: every
|
||||||
|
KANBANTE_SNAPSHOT_INTERVAL seconds it force-refreshes the hot
|
||||||
|
keys (repo index, starred, issues + milestones of active
|
||||||
|
repos). Every fetch write-throughs to the SQLite `snapshots`
|
||||||
|
table, and the client cache is hydrated from that table at
|
||||||
|
startup — so pages read local data from the very first request
|
||||||
|
and only ever wait on Gitea when no snapshot exists yet.
|
||||||
|
|
||||||
|
Freshness contract, shown in the UI: Gitea-side data may lag
|
||||||
|
by up to one interval; a Gitea webhook refreshes the touched
|
||||||
|
repo immediately (see refresh_repo_snapshots); anything the
|
||||||
|
user does locally (board journal) is always instant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("kanbante.snapshots")
|
||||||
|
|
||||||
|
_REFRESH_CONCURRENCY = 8
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_repo_snapshots(gitea, owner: str, repo: str) -> None:
|
||||||
|
"""Refresh one repo's hot keys (webhooks call this).
|
||||||
|
|
||||||
|
Both issue states are refreshed: most pages read "all", but
|
||||||
|
Focus/Triage/Mes issues read "open" — a snapshot set missing
|
||||||
|
either leaves a slow first hit on the table.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await gitea.refresh_repo_issues(owner, repo, "all")
|
||||||
|
await gitea.refresh_repo_issues(owner, repo, "open")
|
||||||
|
await gitea.refresh_milestones(owner, repo)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("repo snapshot refresh failed for %s/%s: %s", owner, repo, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_snapshots(gitea, classify_repos) -> int:
|
||||||
|
"""One full refresh cycle. Returns the active-repo count."""
|
||||||
|
repos = await gitea.refresh_search_repos()
|
||||||
|
try:
|
||||||
|
await gitea.refresh_starred()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("starred refresh failed: %s", exc)
|
||||||
|
active, _inactive, _archived = classify_repos(repos)
|
||||||
|
semaphore = asyncio.Semaphore(_REFRESH_CONCURRENCY)
|
||||||
|
|
||||||
|
async def _one(repo: dict) -> None:
|
||||||
|
owner, _, name = repo["full_name"].partition("/")
|
||||||
|
async with semaphore:
|
||||||
|
await refresh_repo_snapshots(gitea, owner, name)
|
||||||
|
|
||||||
|
await asyncio.gather(*(_one(repo) for repo in active))
|
||||||
|
logger.info("snapshot refresh complete (%d active repos)", len(active))
|
||||||
|
return len(active)
|
||||||
|
|
||||||
|
|
||||||
|
async def snapshot_loop(gitea, classify_repos) -> None:
|
||||||
|
"""Periodic refresher, started by the app lifespan.
|
||||||
|
|
||||||
|
Sleeps briefly first: hydration already serves the pages,
|
||||||
|
the loop only has to keep the snapshots warm.
|
||||||
|
"""
|
||||||
|
interval = get_settings().snapshot_interval
|
||||||
|
if interval <= 0:
|
||||||
|
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||||
|
return
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await refresh_snapshots(gitea, classify_repos)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
def freshness_label() -> str | None:
|
||||||
|
"""« à l'instant » / « il y a N min » from the freshest snapshot.
|
||||||
|
|
||||||
|
Honest freshness display (chantier B contract): the sidebar
|
||||||
|
shows how old the Gitea data on screen can be.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
latest = models.latest_snapshot_at()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not latest:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
fetched = datetime.datetime.strptime(
|
||||||
|
latest, "%Y-%m-%d %H:%M:%S"
|
||||||
|
).replace(tzinfo=datetime.timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
age = (
|
||||||
|
datetime.datetime.now(datetime.timezone.utc) - fetched
|
||||||
|
).total_seconds()
|
||||||
|
if age < 90:
|
||||||
|
return "à l'instant"
|
||||||
|
if age < 3600:
|
||||||
|
return f"il y a {int(age // 60)} min"
|
||||||
|
if age < 86400:
|
||||||
|
return f"il y a {int(age // 3600)} h"
|
||||||
|
return f"il y a {int(age // 86400)} j"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||||
|
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.2
|
||||||
|
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||||
|
passe par une nouvelle version de commons.
|
||||||
|
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
|
||||||
|
Inclus par _header.html (toutes les pages applicatives ; ni le
|
||||||
|
wallboard ni l'écran de connexion, volontaires). #}
|
||||||
|
<link rel="stylesheet" href="/static/css/draco-barre.css">
|
||||||
|
<script>document.body.classList.add('has-draco-bar');</script>
|
||||||
|
<header class="draco-bar" data-current="kanbante">
|
||||||
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
|
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||||
|
<a class="draco-brand" href="/" aria-label="dracodev">
|
||||||
|
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||||
|
</a>
|
||||||
|
<nav class="draco-apptabs" aria-label="Applications">
|
||||||
|
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||||
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||||
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||||
|
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||||
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||||
|
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="draco-bar-zone draco-bar-centre">
|
||||||
|
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
|
||||||
|
<span class="draco-search-ico">⌕</span>
|
||||||
|
<span class="draco-search-txt">Rechercher partout…</span>
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="draco-bar-zone draco-bar-droite">
|
||||||
|
<div class="draco-domaines" role="group" aria-label="Domaine">
|
||||||
|
<span class="draco-domaines-lbl">Domaine</span>
|
||||||
|
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
|
||||||
|
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
|
||||||
|
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||||
|
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||||
|
<span class="draco-alertes-txt">Alertes</span>
|
||||||
|
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||||
|
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||||
|
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||||
|
<span class="draco-avatar" title="Opérateur Kanbante">{{ draco_avatar }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||||
|
<script>
|
||||||
|
/* Configuration Kanbante de la barre commune (Fusion ACC-1).
|
||||||
|
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||||
|
le rail pilote le repli natif de la barre latérale, la palette
|
||||||
|
ouvre la recherche & commandes native (Ctrl+K) ; les alertes
|
||||||
|
unifiées, la recherche fédérée et Spark arrivent avec les
|
||||||
|
phases suivantes du programme Fusion. */
|
||||||
|
window.DRACO_BAR_CONFIG = {
|
||||||
|
current: "kanbante",
|
||||||
|
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||||
|
counts: {},
|
||||||
|
onAction: function (act, el) {
|
||||||
|
var dire = function (m) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.textContent = m;
|
||||||
|
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:3000;background:#1f1f1f;color:#eee;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
|
||||||
|
document.body.appendChild(d);
|
||||||
|
setTimeout(function () { d.remove(); }, 3400);
|
||||||
|
};
|
||||||
|
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||||
|
if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; }
|
||||||
|
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
|
||||||
|
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||||
|
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
||||||
|
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
||||||
|
if (act === "rappel-entete") {
|
||||||
|
var bar = document.querySelector(".draco-bar");
|
||||||
|
if (bar) bar.style.display = "";
|
||||||
|
el.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script defer src="/static/js/draco-barre.js"></script>
|
||||||
+39
-16
@@ -1,3 +1,4 @@
|
|||||||
|
{% include "_draco_bar.html" %}
|
||||||
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
|
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
|
||||||
Barre latérale unique pour TOUTES les pages applicatives :
|
Barre latérale unique pour TOUTES les pages applicatives :
|
||||||
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
|
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
|
||||||
@@ -6,18 +7,6 @@
|
|||||||
Page active : `active_nav` (déduite du gabarit dans render()).
|
Page active : `active_nav` (déduite du gabarit dans render()).
|
||||||
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
|
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
|
||||||
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
||||||
<script>
|
|
||||||
document.body.classList.add('with-sidebar');
|
|
||||||
try {
|
|
||||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
|
||||||
document.body.classList.add('sidebar-collapsed');
|
|
||||||
}
|
|
||||||
JSON.parse(localStorage.getItem('kanbante-nav-closed') || '[]')
|
|
||||||
.forEach(function (k) {
|
|
||||||
document.body.classList.add('nav-closed-' + k);
|
|
||||||
});
|
|
||||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
|
||||||
</script>
|
|
||||||
{% set nav_groups = [
|
{% set nav_groups = [
|
||||||
("principal", "Principal", [
|
("principal", "Principal", [
|
||||||
("home", "/", "🏠", "Projets"),
|
("home", "/", "🏠", "Projets"),
|
||||||
@@ -42,6 +31,37 @@
|
|||||||
("wall", "/wall", "🖥️", "Wallboard"),
|
("wall", "/wall", "🖥️", "Wallboard"),
|
||||||
]),
|
]),
|
||||||
] %}
|
] %}
|
||||||
|
{% set active_group = namespace(key="principal") %}
|
||||||
|
{% for gkey, group, links in nav_groups %}
|
||||||
|
{% for key, href, icon, label in links %}
|
||||||
|
{% if active_nav == key %}{% set active_group.key = gkey %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<script>
|
||||||
|
document.body.classList.add('with-sidebar');
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||||
|
document.body.classList.add('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
/* Accordéon exclusif (0.22.0) : une seule section ouverte.
|
||||||
|
La section de la page courante prime, sinon celle
|
||||||
|
mémorisée, sinon « principal ». */
|
||||||
|
localStorage.removeItem('kanbante-nav-closed');
|
||||||
|
var openGroup = {{ active_group.key | tojson }};
|
||||||
|
var stored = localStorage.getItem('kanbante-nav-open');
|
||||||
|
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
|
||||||
|
if ({{ (active_nav != "") | tojson }}) {
|
||||||
|
localStorage.setItem('kanbante-nav-open', openGroup);
|
||||||
|
} else if (stored === '') {
|
||||||
|
openGroup = '';
|
||||||
|
} else if (stored !== null && groups.indexOf(stored) >= 0) {
|
||||||
|
openGroup = stored;
|
||||||
|
}
|
||||||
|
groups.forEach(function (k) {
|
||||||
|
if (k !== openGroup) document.body.classList.add('nav-closed-' + k);
|
||||||
|
});
|
||||||
|
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||||
|
</script>
|
||||||
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
||||||
<div class="side-head">
|
<div class="side-head">
|
||||||
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
||||||
@@ -49,6 +69,7 @@
|
|||||||
<span class="side-label gradient-text">Kanbante</span>
|
<span class="side-label gradient-text">Kanbante</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||||
|
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="side-nav">
|
<nav class="side-nav">
|
||||||
{% for gkey, group, links in nav_groups %}
|
{% for gkey, group, links in nav_groups %}
|
||||||
@@ -71,11 +92,13 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
<button type="button" class="side-collapse" id="side-collapse" aria-label="Replier ou déplier le menu">
|
{% if data_freshness %}
|
||||||
<span class="side-collapse-ico">«</span>
|
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
|
||||||
<span class="side-label">Replier</span>
|
{% endif %}
|
||||||
</button>
|
|
||||||
<span class="side-version side-label">v{{ version }}</span>
|
<span class="side-version side-label">v{{ version }}</span>
|
||||||
|
{% if auth_enabled %}
|
||||||
|
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Connexion — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main class="login-wrap">
|
||||||
|
<section class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<span class="login-mark" aria-hidden="true">📋</span>
|
||||||
|
<h1>Kanbante</h1>
|
||||||
|
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
|
||||||
|
</div>
|
||||||
|
{% if error %}
|
||||||
|
<p class="login-error" role="alert">⚠️ {{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login" class="login-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<label>Nom d'utilisateur
|
||||||
|
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||||
|
</label>
|
||||||
|
<label>Mot de passe
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="login-submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
<a class="login-sso" href="https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fkanbante.dracodev.net%2Fauth%2Fdraco">🧭 Se connecter avec ObsiGate (SSO)</a>
|
||||||
|
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.21.0"
|
version = "0.23.1"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
|
||||||
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px).
|
||||||
|
Jetons repris du prototype validé Console v7.2.
|
||||||
|
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||||
|
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||||
|
|
||||||
|
.draco-bar {
|
||||||
|
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||||
|
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||||
|
--agent: #c2437e; --danger: #cf4444;
|
||||||
|
height: 60px; box-sizing: border-box;
|
||||||
|
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||||
|
align-items: center; gap: 16px; padding: 0 12px;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||||
|
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||||
|
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||||
|
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||||
|
|
||||||
|
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.draco-bar-centre { justify-content: center; }
|
||||||
|
.draco-bar-droite { justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Marque */
|
||||||
|
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
|
||||||
|
.draco-brand-mark { color: var(--agent); }
|
||||||
|
|
||||||
|
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
|
||||||
|
.draco-apptabs { display: flex; gap: 6px; }
|
||||||
|
.draco-apptab {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 8px;
|
||||||
|
height: 44px; padding: 0 9px; border-radius: 12px;
|
||||||
|
color: var(--ink); text-decoration: none; background: transparent;
|
||||||
|
transition: background .18s ease;
|
||||||
|
}
|
||||||
|
.draco-apptab:hover { background: var(--surface-2); }
|
||||||
|
.draco-apptab-ico {
|
||||||
|
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||||
|
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||||
|
}
|
||||||
|
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
|
||||||
|
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
|
||||||
|
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
|
||||||
|
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
|
||||||
|
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
|
||||||
|
|
||||||
|
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
|
||||||
|
.draco-badge {
|
||||||
|
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
|
||||||
|
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
|
||||||
|
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
|
||||||
|
display: grid; place-items: center; border: 2px solid var(--surface);
|
||||||
|
}
|
||||||
|
.draco-badge[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Recherche */
|
||||||
|
.draco-search {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
|
||||||
|
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
|
||||||
|
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
|
||||||
|
}
|
||||||
|
.draco-search kbd {
|
||||||
|
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
|
||||||
|
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Domaines — contrôle segmenté */
|
||||||
|
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
|
||||||
|
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
|
||||||
|
.draco-domaines button {
|
||||||
|
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
|
||||||
|
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
|
||||||
|
|
||||||
|
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
|
||||||
|
.draco-alertes {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||||
|
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
|
||||||
|
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spark */
|
||||||
|
.draco-spark {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||||
|
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
|
||||||
|
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
|
||||||
|
}
|
||||||
|
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||||
|
|
||||||
|
.draco-icon-btn {
|
||||||
|
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||||
|
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||||
|
.draco-avatar {
|
||||||
|
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||||
|
background: #2f6fed; color: #fff; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||||
|
.draco-bar-rappel {
|
||||||
|
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
|
||||||
|
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
|
||||||
|
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
|
||||||
|
@media (max-width: 1440px) {
|
||||||
|
.draco-brand-name, .draco-domaines-lbl { display: none; }
|
||||||
|
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
|
||||||
|
.draco-search { width: 38px; padding: 0; justify-content: center; }
|
||||||
|
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||||
|
.draco-bar-droite { justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||||
|
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||||
|
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
||||||
|
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||||
|
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||||
|
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||||
|
restaient affichés et la barre débordait sur téléphone.) */
|
||||||
|
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
|
||||||
|
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
|
||||||
|
.draco-apptab { display: none; }
|
||||||
|
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||||
|
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||||
|
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||||
|
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||||
|
}
|
||||||
+102
-7
@@ -1210,16 +1210,22 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Barre latérale (0.16.0, #16 refonte) ── */
|
/* ── Barre latérale (0.16.0, #16 refonte) ── */
|
||||||
body.with-sidebar { padding-left: 238px; transition: padding-left 0.18s ease; }
|
/* Fluidité (0.23.1) : durée et courbe UNIFIÉES entre le padding du
|
||||||
|
corps et la largeur de la barre (avant : 0.18 s ease des deux
|
||||||
|
côtés mais transform à 0.22 s — mouvements désynchronisés), et
|
||||||
|
retrait du backdrop-filter : le fond est déjà opaque à 96 %,
|
||||||
|
le flou forçait un re-rendu coûteux à chaque image de l'animation
|
||||||
|
de largeur, d'où l'effet « lent et saccadé » signalé par Bruno. */
|
||||||
|
body.with-sidebar { padding-left: 238px; transition: padding-left 0.26s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||||
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
|
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
|
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
|
||||||
width: 238px; box-sizing: border-box;
|
width: 238px; box-sizing: border-box;
|
||||||
display: flex; flex-direction: column; gap: 6px;
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
|
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
|
||||||
background: rgba(13, 16, 24, 0.96); backdrop-filter: blur(14px);
|
background: rgba(13, 16, 24, 0.96);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
transition: width 0.18s ease, transform 0.22s ease;
|
transition: width 0.26s cubic-bezier(0.4, 0, 0.2, 1), transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
body.sidebar-collapsed .sidebar { width: 68px; }
|
body.sidebar-collapsed .sidebar { width: 68px; }
|
||||||
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
|
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
|
||||||
@@ -1297,11 +1303,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
|
|||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
}
|
}
|
||||||
.side-collapse {
|
.side-collapse {
|
||||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
|
||||||
background: transparent; border: 1px solid var(--border); border-radius: 9px;
|
barre, à droite de la recherche (même gabarit qu'elle). */
|
||||||
color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap;
|
flex-shrink: 0;
|
||||||
|
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||||
}
|
}
|
||||||
.side-collapse:hover { color: var(--text); }
|
.side-collapse:hover {
|
||||||
|
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
|
||||||
|
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
|
||||||
|
.side-logout:hover { color: var(--text); text-decoration: none; }
|
||||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||||
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||||
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
||||||
@@ -1541,3 +1558,81 @@ a.ms-title:hover { text-decoration: underline; }
|
|||||||
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
||||||
}
|
}
|
||||||
.capture-form input.repo-unknown { border-color: #f0a35e; }
|
.capture-form input.repo-unknown { border-color: #f0a35e; }
|
||||||
|
|
||||||
|
/* ── Écran de connexion (0.22.0) ────────────── */
|
||||||
|
.login-body {
|
||||||
|
min-height: 100dvh; margin: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
|
||||||
|
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
|
||||||
|
var(--bg, #0a0f16);
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
.login-wrap { width: 100%; max-width: 400px; }
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface, #10151f);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 34px 32px 26px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.login-brand { text-align: center; margin-bottom: 22px; }
|
||||||
|
.login-mark {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
|
||||||
|
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.login-brand h1 {
|
||||||
|
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
|
||||||
|
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
|
||||||
|
.login-error {
|
||||||
|
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
|
||||||
|
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
|
||||||
|
color: #f5a58c; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
|
||||||
|
.login-form input {
|
||||||
|
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
|
||||||
|
font: inherit; font-weight: 400;
|
||||||
|
}
|
||||||
|
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
|
||||||
|
.login-submit {
|
||||||
|
margin-top: 4px; padding: 12px 18px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
|
||||||
|
color: #fff; border: none; border-radius: 10px;
|
||||||
|
font: inherit; font-weight: 600; font-size: 1rem;
|
||||||
|
transition: filter 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.login-submit:hover { filter: brightness(1.12); }
|
||||||
|
.login-submit:active { transform: scale(0.98); }
|
||||||
|
.login-sso {
|
||||||
|
display: block; margin-top: 12px; padding: 11px 18px; text-align: center;
|
||||||
|
color: var(--text-1, #e6ebf2); text-decoration: none;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
font: inherit; font-weight: 500; font-size: 0.95rem;
|
||||||
|
transition: filter 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.login-sso:hover { filter: brightness(1.35); }
|
||||||
|
.login-sso:active { transform: scale(0.98); }
|
||||||
|
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
/* ── Fusion ACC-1 (kanbante#28) : cohabitation avec la barre commune ──
|
||||||
|
La barre d'apps est FIXE et pleine largeur (0.23.1 — en flux, le
|
||||||
|
padding-left du cadre la repoussait à droite de la barre latérale,
|
||||||
|
constat de Bruno sur capture) ; le cadre entier est décalé de sa
|
||||||
|
hauteur, et la barre latérale fixe démarre sous elle. */
|
||||||
|
body.has-draco-bar { padding-top: 60px; }
|
||||||
|
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
|
||||||
|
body.has-draco-bar .sidebar { top: 60px; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
body.has-draco-bar { padding-top: 52px; }
|
||||||
|
body.has-draco-bar .sidebar { top: 52px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
|
||||||
|
*
|
||||||
|
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||||
|
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||||
|
* proscrits dans tout le programme).
|
||||||
|
*
|
||||||
|
* Configuration attendue avant le chargement :
|
||||||
|
* window.DRACO_BAR_CONFIG = {
|
||||||
|
* current: "tiktak", // app courante
|
||||||
|
* domain: "pro", // domaine actif
|
||||||
|
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||||
|
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||||
|
* } // qu'elle ne connaît pas
|
||||||
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
|
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
||||||
|
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||||
|
|
||||||
|
function setCounts(counts) {
|
||||||
|
Object.keys(counts || {}).forEach(function (k) {
|
||||||
|
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
|
||||||
|
if (!b) return;
|
||||||
|
b.textContent = counts[k];
|
||||||
|
b.hidden = !(counts[k] > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setCurrent(app) {
|
||||||
|
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||||
|
t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setDomain(dom) {
|
||||||
|
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
|
||||||
|
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
var el = ev.target.closest("[data-act]");
|
||||||
|
if (!el) return;
|
||||||
|
var act = el.dataset.act;
|
||||||
|
if (act === "domaine") setDomain(el.dataset.dom);
|
||||||
|
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||||
|
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||||
|
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||||
|
|
||||||
|
if (cfg.current) setCurrent(cfg.current);
|
||||||
|
if (cfg.domain) setDomain(cfg.domain);
|
||||||
|
if (cfg.counts) setCounts(cfg.counts);
|
||||||
|
})();
|
||||||
+18
-31
@@ -43,46 +43,33 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accordion sections: the closed set lives in body classes
|
/* Accordéon EXCLUSIF (0.22.0) : une seule section ouverte
|
||||||
(applied pre-paint by the bootstrap script in _header.html)
|
à la fois. Ouvrir une section ferme les autres ; cliquer
|
||||||
and is persisted here on every toggle. */
|
la section ouverte la referme. L'état initial vient du
|
||||||
|
script d'amorce de _header.html (section de la page
|
||||||
|
courante ou choix mémorisé dans kanbante-nav-open). */
|
||||||
var groupBtns = document.querySelectorAll('[data-group-btn]');
|
var groupBtns = document.querySelectorAll('[data-group-btn]');
|
||||||
function persistClosedGroups() {
|
|
||||||
var closed = [];
|
|
||||||
groupBtns.forEach(function (btn) {
|
|
||||||
if (document.body.classList.contains('nav-closed-' + btn.getAttribute('data-group-btn'))) {
|
|
||||||
closed.push(btn.getAttribute('data-group-btn'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
localStorage.setItem('kanbante-nav-closed', JSON.stringify(closed));
|
|
||||||
} catch (e) { /* non-fatal */ }
|
|
||||||
}
|
|
||||||
function syncGroupBtn(btn) {
|
function syncGroupBtn(btn) {
|
||||||
var closed = document.body.classList.contains(
|
var closed = document.body.classList.contains(
|
||||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||||
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||||
}
|
}
|
||||||
|
function setOpenGroup(openKey) {
|
||||||
|
groupBtns.forEach(function (btn) {
|
||||||
|
var key = btn.getAttribute('data-group-btn');
|
||||||
|
document.body.classList.toggle('nav-closed-' + key, key !== openKey);
|
||||||
|
syncGroupBtn(btn);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
localStorage.setItem('kanbante-nav-open', openKey);
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
}
|
||||||
groupBtns.forEach(function (btn) {
|
groupBtns.forEach(function (btn) {
|
||||||
syncGroupBtn(btn);
|
syncGroupBtn(btn);
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
document.body.classList.toggle(
|
var key = btn.getAttribute('data-group-btn');
|
||||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
var isOpen = !document.body.classList.contains('nav-closed-' + key);
|
||||||
syncGroupBtn(btn);
|
setOpenGroup(isOpen ? '' : key);
|
||||||
persistClosedGroups();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/* The section holding the current page is always opened. */
|
|
||||||
var activeLink = document.querySelector('.sidebar .side-link.active');
|
|
||||||
if (activeLink) {
|
|
||||||
var section = activeLink.closest('.side-section');
|
|
||||||
if (section) {
|
|
||||||
var key = section.getAttribute('data-group');
|
|
||||||
if (document.body.classList.contains('nav-closed-' + key)) {
|
|
||||||
document.body.classList.remove('nav-closed-' + key);
|
|
||||||
groupBtns.forEach(syncGroupBtn);
|
|
||||||
persistClosedGroups();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -271,6 +271,26 @@ class FakeGitea:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
|
||||||
|
# l'interface du vrai client.
|
||||||
|
def hydrate_from_snapshots(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def latest_snapshot_at(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def refresh_search_repos(self):
|
||||||
|
return await self.search_repos()
|
||||||
|
|
||||||
|
async def refresh_starred(self):
|
||||||
|
return await self.list_starred()
|
||||||
|
|
||||||
|
async def refresh_repo_issues(self, owner, repo, state="all"):
|
||||||
|
return await self.get_repo_issues(owner, repo, state=state)
|
||||||
|
|
||||||
|
async def refresh_milestones(self, owner, repo):
|
||||||
|
return await self.get_milestones(owner, repo)
|
||||||
|
|
||||||
async def list_starred(self):
|
async def list_starred(self):
|
||||||
return [r for r in self.repos if r["full_name"] in self.starred]
|
return [r for r in self.repos if r["full_name"] in self.starred]
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ def test_board_hides_pull_requests_and_reconciles(client):
|
|||||||
assert [c["issue_number"] for c in state["done"]] == [3] # reconciled
|
assert [c["issue_number"] for c in state["done"]] == [3] # reconciled
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_renders_closed_issue_in_done_column(client):
|
||||||
|
# Regression: reconcile moved the closed issue's row to "done", but
|
||||||
|
# the board merged open issues only, so the card never rendered and
|
||||||
|
# the "Terminé" column stayed empty.
|
||||||
|
models.move_issue("Projets/demo", 3, "todo") # closed in Gitea
|
||||||
|
r = client.get("/board/Projets/demo")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Ancienne tâche" in r.text
|
||||||
|
payload = client.get("/api/board/Projets/demo").json()
|
||||||
|
assert payload["columns"]["done"]["count"] == 1
|
||||||
|
assert "Ancienne tâche" in payload["columns"]["done"]["html"]
|
||||||
|
|
||||||
|
|
||||||
def test_focus_lists_in_progress_across_projects(client):
|
def test_focus_lists_in_progress_across_projects(client):
|
||||||
models.move_issue("Projets/demo", 5, "in_progress")
|
models.move_issue("Projets/demo", 5, "in_progress")
|
||||||
r = client.get("/focus")
|
r = client.get("/focus")
|
||||||
|
|||||||
@@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea):
|
|||||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
with TestClient(main.app) as client:
|
with TestClient(main.app) as client:
|
||||||
|
# Sans en-tête navigateur : 401 (comportement historique).
|
||||||
assert client.get("/").status_code == 401
|
assert client.get("/").status_code == 401
|
||||||
|
# Le Basic reste accepté pour les scripts (0.22.0).
|
||||||
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
|
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
|
||||||
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
|
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
|
||||||
|
# Un navigateur, lui, est envoyé vers l'écran de connexion.
|
||||||
|
r = client.get(
|
||||||
|
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"].startswith("/login")
|
||||||
# Liveness stays public for Docker HEALTHCHECK.
|
# Liveness stays public for Docker HEALTHCHECK.
|
||||||
assert client.get("/api/health").status_code == 200
|
assert client.get("/api/health").status_code == 200
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Fusion ACC-1 — barre d'apps commune (carte kanbante#28).
|
||||||
|
|
||||||
|
Le snippet figé de dracodev-commons est vendangé (CSS/JS statiques +
|
||||||
|
partiel _draco_bar.html) et inclus par le cadre partagé _header.html :
|
||||||
|
toutes les pages applicatives l'affichent avec Kanbante comme app
|
||||||
|
courante ; l'écran de connexion, autonome, ne l'affiche pas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_barre_presente_sur_le_dashboard(client):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'class="draco-bar"' in r.text
|
||||||
|
assert 'data-current="kanbante"' in r.text
|
||||||
|
assert "draco-barre.css" in r.text
|
||||||
|
assert "draco-barre.js" in r.text
|
||||||
|
assert 'window.DRACO_BAR_CONFIG' in r.text
|
||||||
|
# Les quatre applications de la barre, aux URL publiques.
|
||||||
|
for url in ("https://og.dracodev.net/", "https://flowdeck.dracodev.net/",
|
||||||
|
"https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"):
|
||||||
|
assert url in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_statiques_de_la_barre_servis(client):
|
||||||
|
assert client.get("/static/css/draco-barre.css").status_code == 200
|
||||||
|
assert client.get("/static/js/draco-barre.js").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_barre_pleine_largeur_et_palier_mobile(client):
|
||||||
|
# 0.23.1 : la barre est fixe pleine largeur (le padding du cadre
|
||||||
|
# ne la repousse plus) et le palier mobile commons v0.1.2 ne
|
||||||
|
# garde que l'onglet de l'app courante.
|
||||||
|
style = client.get("/static/css/style.css").text
|
||||||
|
assert "body.has-draco-bar .draco-bar" in style
|
||||||
|
assert "position: fixed" in style
|
||||||
|
barre = client.get("/static/css/draco-barre.css").text
|
||||||
|
assert '.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"]' in barre
|
||||||
|
|
||||||
|
|
||||||
|
def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
r = client.get("/login")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "draco-bar" not in r.text
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Fusion ACC-3.2 — pont SSO draco_session (carte kanbante#27).
|
||||||
|
|
||||||
|
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
|
||||||
|
(secret partagé, HS256) et converti en session Kanbante native par
|
||||||
|
GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide
|
||||||
|
ouvre la session de l'opérateur configuré. Sans
|
||||||
|
DRACO_SESSION_SECRET, le pont renvoie simplement au login.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import draco
|
||||||
|
|
||||||
|
SECRET = "secret-partage-de-test"
|
||||||
|
|
||||||
|
|
||||||
|
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"):
|
||||||
|
def b64(d: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||||
|
payload = b64(json.dumps({
|
||||||
|
"sub": sub, "name": "Bruno", "iat": int(time.time()),
|
||||||
|
"exp": exp if exp is not None else int(time.time()) + 3600,
|
||||||
|
"jti": jti,
|
||||||
|
}).encode())
|
||||||
|
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
||||||
|
hashlib.sha256).digest()
|
||||||
|
return f"{header}.{payload}.{b64(sig)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client_sso(monkeypatch, fake_gitea):
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
|
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||||
|
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||||
|
draco._revoked_cache.clear()
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
yield client
|
||||||
|
draco._revoked_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_token(monkeypatch):
|
||||||
|
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||||
|
assert draco.verify_token(_jeton())["sub"] == "admin-1"
|
||||||
|
assert draco.verify_token(_jeton(secret="mauvais")) is None
|
||||||
|
assert draco.verify_token(_jeton(exp=int(time.time()) - 5)) is None
|
||||||
|
assert draco.verify_token("pas.un.jeton") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pont_sans_cookie_vers_login(client_sso):
|
||||||
|
r = client_sso.get("/auth/draco", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pont_cookie_valide_ouvre_la_session(client_sso):
|
||||||
|
client_sso.cookies.set("draco_session", _jeton())
|
||||||
|
r = client_sso.get("/auth/draco", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"] == "/"
|
||||||
|
assert "kanbante_session" in r.headers.get("set-cookie", "")
|
||||||
|
# La session native créée par le pont donne accès à l'app.
|
||||||
|
assert client_sso.get("/").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_pont_cookie_falsifie_vers_login(client_sso):
|
||||||
|
client_sso.cookies.set("draco_session", _jeton(secret="mauvais"))
|
||||||
|
r = client_sso.get("/auth/draco", follow_redirects=False)
|
||||||
|
assert r.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pont_jeton_revoque_refuse(client_sso):
|
||||||
|
draco._revoked_cache["jti-revoque"] = int(time.time()) + 3600
|
||||||
|
client_sso.cookies.set("draco_session", _jeton(jti="jti-revoque"))
|
||||||
|
r = client_sso.get("/auth/draco", follow_redirects=False)
|
||||||
|
assert r.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pont_inerte_sans_secret(monkeypatch, fake_gitea):
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
|
monkeypatch.delenv("DRACO_SESSION_SECRET", raising=False)
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
client.cookies.set("draco_session", _jeton())
|
||||||
|
r = client.get("/auth/draco", follow_redirects=False)
|
||||||
|
assert r.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_sso_sur_la_page_login(client_sso):
|
||||||
|
r = client_sso.get("/login")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "og.dracodev.net/sso/authorize" in r.text
|
||||||
|
assert "kanbante.dracodev.net%2Fauth%2Fdraco" in r.text
|
||||||
@@ -64,7 +64,8 @@ def test_nav_js_contract(client, fake_gitea):
|
|||||||
assert "localStorage" in js.text
|
assert "localStorage" in js.text
|
||||||
assert "side-backdrop" in js.text
|
assert "side-backdrop" in js.text
|
||||||
assert "Escape" in js.text
|
assert "Escape" in js.text
|
||||||
assert "kanbante-nav-closed" in js.text # accordéons persistés
|
assert "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0)
|
||||||
|
assert "setOpenGroup" in js.text
|
||||||
assert "data-group-btn" in js.text
|
assert "data-group-btn" in js.text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Palier 18 (0.22.0) — login screen, sidebar, chantier B.
|
||||||
|
|
||||||
|
- Connexion par formulaire + cookie de session signé (le Basic
|
||||||
|
reste accepté pour les scripts) ; les navigateurs sont
|
||||||
|
redirigés vers /login, les API reçoivent 401.
|
||||||
|
- Bouton replier dans l'en-tête de la barre latérale (icône
|
||||||
|
seule, à droite de la recherche) ; accordéon exclusif.
|
||||||
|
- Instantanés : persistance des réponses du client, hydratation
|
||||||
|
au démarrage, étiquette de fraîcheur.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import models
|
||||||
|
from app.gitea_client import GiteaClient
|
||||||
|
from app.snapshots import freshness_label
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_client(monkeypatch, fake_gitea):
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
|
return TestClient(main.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_from(html: str) -> str:
|
||||||
|
m = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||||
|
assert m, "le formulaire de connexion doit porter le jeton CSRF"
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_is_redirected_to_login(monkeypatch, fake_gitea):
|
||||||
|
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||||
|
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"].startswith("/login")
|
||||||
|
# Les API, elles, reçoivent un 401 sec (pas de redirection).
|
||||||
|
r = client.get("/api/repos")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_flow_and_logout(monkeypatch, fake_gitea):
|
||||||
|
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||||
|
page = client.get("/login")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "login-card" in page.text
|
||||||
|
token = _csrf_from(page.text)
|
||||||
|
|
||||||
|
bad = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "bruno", "password": "non", "csrf_token": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert bad.status_code == 401
|
||||||
|
assert "Identifiants incorrects" in bad.text
|
||||||
|
|
||||||
|
ok = client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"username": "bruno",
|
||||||
|
"password": "motdepasse",
|
||||||
|
"csrf_token": token,
|
||||||
|
"next": "/focus",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert ok.status_code == 303
|
||||||
|
assert ok.headers["location"] == "/focus"
|
||||||
|
# La session ouvre les pages sans autre en-tête.
|
||||||
|
assert client.get("/").status_code == 200
|
||||||
|
|
||||||
|
client.get("/logout")
|
||||||
|
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_next_is_local_only(monkeypatch, fake_gitea):
|
||||||
|
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||||
|
token = _csrf_from(client.get("/login").text)
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"username": "bruno",
|
||||||
|
"password": "motdepasse",
|
||||||
|
"csrf_token": token,
|
||||||
|
"next": "https://evil.example/phish",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_head_and_exclusive_accordion(client):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
head = r.text.split('class="side-nav"')[0]
|
||||||
|
assert 'id="side-collapse"' in head # replier dans l'en-tête…
|
||||||
|
assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche
|
||||||
|
assert '<span class="side-label">Replier</span>' not in r.text # icône seule
|
||||||
|
assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_write_through_and_hydration():
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls["n"] += 1
|
||||||
|
if request.url.path.endswith("/user"):
|
||||||
|
return httpx.Response(200, json={"id": 7})
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"data": [{"full_name": "Projets/demo", "archived": False}]},
|
||||||
|
headers={"X-Total-Count": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = GiteaClient(
|
||||||
|
base_url="http://gitea.test", cache_ttl=30,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
repos = await writer.search_repos()
|
||||||
|
assert repos and repos[0]["full_name"] == "Projets/demo"
|
||||||
|
stored = models.load_snapshots()
|
||||||
|
assert "search_repos" in stored
|
||||||
|
|
||||||
|
def boom(request):
|
||||||
|
raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit")
|
||||||
|
|
||||||
|
reader = GiteaClient(
|
||||||
|
base_url="http://gitea.test", cache_ttl=30,
|
||||||
|
transport=httpx.MockTransport(boom),
|
||||||
|
)
|
||||||
|
assert reader.hydrate_from_snapshots() >= 1
|
||||||
|
served = await reader.search_repos()
|
||||||
|
assert served[0]["full_name"] == "Projets/demo"
|
||||||
|
assert freshness_label() == "à l'instant"
|
||||||
Reference in New Issue
Block a user