Compare commits

...
8 Commits
15 changed files with 743 additions and 12 deletions
+51
View File
@@ -1,5 +1,56 @@
# 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)
+18 -1
View File
@@ -14,6 +14,15 @@ Environment variables:
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).
@@ -95,7 +104,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.22.0"
APP_VERSION = "0.23.1"
def _env_bool(name: str, default: bool) -> bool:
@@ -166,6 +175,10 @@ class Settings:
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
def auth_enabled(self) -> bool:
@@ -213,4 +226,8 @@ def get_settings() -> Settings:
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
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
"clientInfo": {"name": "kanbante", "version": "0.22.1"},
},
req_id=1,
)
+124
View File
@@ -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
+57 -6
View File
@@ -220,7 +220,8 @@ gitea = GiteaClient()
SESSION_COOKIE = "kanbante_session"
SESSION_DAYS = 7
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login")
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login",
"/auth/draco")
def _session_secret() -> bytes:
@@ -374,6 +375,32 @@ async def logout():
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) ─────────────────────────────────
class LiveHub:
@@ -459,6 +486,10 @@ def render(
token = secrets.token_urlsafe(32)
context.setdefault("csrf_token", token)
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("auth_enabled", get_settings().auth_enabled)
context.setdefault("data_freshness", snapshots_service.freshness_label())
@@ -832,13 +863,25 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
# A kanban:* label changed outside Kanbante wins over local state.
_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),
# with its deadline and completion progress.
milestones: list[dict] = []
selected_milestone = None
milestone_progress = None
milestone_forecast = None
display_issues = open_issues
display_issues = open_issues + closed_display
try:
milestones = await gitea.get_milestones(owner, repo)
except Exception as exc: # milestones are decoration, never fatal
@@ -851,7 +894,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
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(
(
m for m in milestones
@@ -941,7 +984,16 @@ def _board_columns_for(
for number in changes["closed_numbers"]:
log_event(full_name, number, "closed", to_column="done", source="gitea")
_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:
def _in_ms(issue: dict) -> bool:
ms = issue.get("milestone") or {}
@@ -949,8 +1001,7 @@ def _board_columns_for(
str(ms.get("id")) == milestone or ms.get("title") == milestone
)
display_issues = [i for i in open_issues if _in_ms(i)]
board_state = get_board_state(full_name)
display_issues = [i for i in display_issues if _in_ms(i)]
columns = _merge_issues_with_board(display_issues, board_state)
return columns, display_issues, board_state
+98
View File
@@ -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>
+1
View File
@@ -1,3 +1,4 @@
{% include "_draco_bar.html" %}
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
+1
View File
@@ -29,6 +29,7 @@
</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>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.22.0"
version = "0.23.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+138
View File
@@ -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; }
}
+31 -3
View File
@@ -1210,16 +1210,22 @@ footer {
}
/* ── 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; }
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
width: 238px; box-sizing: border-box;
display: flex; flex-direction: column; gap: 6px;
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);
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; }
.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; }
@@ -1607,4 +1613,26 @@ a.ms-title:hover { text-decoration: underline; }
}
.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; }
}
+56
View File
@@ -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);
})();
+13
View File
@@ -39,6 +39,19 @@ def test_board_hides_pull_requests_and_reconciles(client):
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):
models.move_issue("Projets/demo", 5, "in_progress")
r = client.get("/focus")
+50
View File
@@ -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
+103
View File
@@ -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