Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2834c332 | ||
|
|
5bc21e1840 | ||
|
|
f087b41b4b |
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 0.23.2 — 2026-10-21
|
||||
|
||||
### Ajouté — menu des applications (barre commune v0.1.3)
|
||||
|
||||
- **Le ✦ de la barre ouvre le menu des 4 applications** (choix de
|
||||
Bruno) : c'est le moyen de changer d'app en mobile, où les
|
||||
onglets sont repliés — et il fonctionne aussi sur bureau. Le
|
||||
logo est un peu plus gros (24 px). Menu construit par le JS
|
||||
commun depuis les onglets (mêmes liens, icônes, badges) ;
|
||||
fermeture au clic extérieur ou à Échap.
|
||||
|
||||
## 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
|
||||
|
||||
+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.2"
|
||||
|
||||
|
||||
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.3
|
||||
(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="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<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.2"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* 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) ;
|
||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21).
|
||||
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;
|
||||
position: relative;
|
||||
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 — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
|
||||
min-width: 248px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
.draco-apps-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
color: var(--ink); text-decoration: none;
|
||||
}
|
||||
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
|
||||
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
|
||||
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
|
||||
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
/* 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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||
* v0.1.3 : menu des applications ouvert par la marque ✦).
|
||||
*
|
||||
* 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…).
|
||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||
|
||||
function setCounts(counts) {
|
||||
Object.keys(counts || {}).forEach(function (k) {
|
||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
|
||||
b.textContent = counts[k];
|
||||
b.hidden = !(counts[k] > 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
function setCurrent(app) {
|
||||
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||
if (t.dataset.app) 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";
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
||||
* Construit depuis les onglets présents dans la barre : mêmes
|
||||
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
||||
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
||||
* choix d'une application (la navigation suit son cours). */
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||
var menu = null;
|
||||
|
||||
function buildMenu() {
|
||||
if (!bar || !brand || menu) return;
|
||||
menu = document.createElement("div");
|
||||
menu.className = "draco-apps-menu";
|
||||
menu.hidden = true;
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = t.getAttribute("href");
|
||||
a.dataset.app = t.dataset.app;
|
||||
a.setAttribute("role", "menuitem");
|
||||
var ico = t.querySelector(".draco-apptab-ico");
|
||||
if (ico) a.appendChild(ico.cloneNode(true));
|
||||
var txt = document.createElement("span");
|
||||
txt.className = "draco-apps-item-txt";
|
||||
var fort = t.querySelector(".draco-apptab-txt strong");
|
||||
var petit = t.querySelector(".draco-apptab-txt small");
|
||||
var nom = document.createElement("span");
|
||||
nom.className = "draco-apps-item-nom";
|
||||
nom.textContent = fort ? fort.textContent : t.dataset.app;
|
||||
txt.appendChild(nom);
|
||||
if (petit) {
|
||||
var role = document.createElement("span");
|
||||
role.className = "draco-apps-item-role";
|
||||
role.textContent = petit.textContent;
|
||||
txt.appendChild(role);
|
||||
}
|
||||
a.appendChild(txt);
|
||||
var badge = t.querySelector(".draco-badge");
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
menu.appendChild(a);
|
||||
});
|
||||
bar.appendChild(menu);
|
||||
}
|
||||
|
||||
function toggleMenu(force) {
|
||||
buildMenu();
|
||||
if (!menu) return;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
toggleMenu();
|
||||
return;
|
||||
}
|
||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
||||
toggleMenu(false);
|
||||
}
|
||||
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 } }));
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") toggleMenu(false);
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
|
||||
buildMenu();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
"""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_menu_des_applications_par_la_marque(client):
|
||||
# v0.1.3 : la marque ✦ porte data-act="apps" et le JS commun
|
||||
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
|
||||
accueil = client.get("/")
|
||||
assert 'data-act="apps"' in accueil.text
|
||||
js = client.get("/static/js/draco-barre.js").text
|
||||
assert "draco-apps-menu" in js
|
||||
css = client.get("/static/css/draco-barre.css").text
|
||||
assert "font-size: 24px" in css
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user