Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f087b41b4b |
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
+18
-1
@@ -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.1"
|
||||
APP_VERSION = "0.23.0"
|
||||
|
||||
|
||||
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("/"),
|
||||
)
|
||||
|
||||
+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
|
||||
+32
-1
@@ -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())
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.1
|
||||
(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,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é),
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.22.1"
|
||||
version = "0.23.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
|
||||
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 : la barre du haut se condense ; la navigation d'apps passe
|
||||
en barre basse propre à chaque app (patron du prototype v5+). */
|
||||
.draco-apptabs, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { height: 52px; }
|
||||
}
|
||||
@@ -1607,4 +1607,18 @@ 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 (60 px, en flux en haut du body) repousse la barre
|
||||
latérale fixe sous elle ; le reste du cadre suit le flux. */
|
||||
body.has-draco-bar .sidebar { top: 60px; }
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -0,0 +1,39 @@
|
||||
"""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_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
|
||||
Reference in New Issue
Block a user