Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a163099048 | ||
|
|
521120e7f0 | ||
|
|
7337c59bed | ||
|
|
32bd64a51b | ||
|
|
485ac62088 | ||
|
|
f57cfc7376 | ||
|
|
7a68606aa7 | ||
|
|
8015d22a06 | ||
|
|
39b5fd88f9 | ||
|
|
f5b007f286 | ||
|
|
2b9b852308 | ||
|
|
d3886b6bf5 | ||
|
|
671ee68c96 | ||
|
|
c46d6dc519 | ||
|
|
4184ba0272 | ||
|
|
751e2f4f44 | ||
|
|
975aff33a5 | ||
|
|
003e44f55e | ||
|
|
53f54b1b79 | ||
|
|
80fc260a40 |
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.37.2 — 2026-10-28 — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
||||||
|
|
||||||
|
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||||
|
|
||||||
|
## 0.37.1 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
|
||||||
|
|
||||||
|
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
|
||||||
|
affiche désormais la ligne discrète et permanente « Service d'agents
|
||||||
|
vX », comme la console pleine page. La version est lue **en direct**
|
||||||
|
dans la santé du service (`GET /healthz` de l'origine que le panneau
|
||||||
|
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
|
||||||
|
une valeur codée en dur ; service injoignable ou panneau non configuré
|
||||||
|
= rien d'affiché, aucune erreur visible.
|
||||||
|
|
||||||
|
## 0.37.0 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — tri « En attente de revue » sur le dashboard
|
||||||
|
|
||||||
|
Demande de Bruno : pouvoir ordonner les projets — section
|
||||||
|
**Épinglés** en tête — par ce qui attend sa validation.
|
||||||
|
|
||||||
|
- Nouveau critère dans le sélecteur de tri du dashboard,
|
||||||
|
en première place après « Par défaut » : **🔍 En attente
|
||||||
|
de revue ↓** — les projets s'ordonnent par le nombre de
|
||||||
|
cartes dans la colonne de validation (clé `review` de
|
||||||
|
l'espace), décroissant.
|
||||||
|
- Le compteur était déjà calculé pour les chips de
|
||||||
|
progression des cartes : il est maintenant exposé en
|
||||||
|
`data-review` sur chaque carte projet et consommé par le
|
||||||
|
moteur de tri partagé — le critère s'applique à **toutes
|
||||||
|
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
|
||||||
|
avec ou sans regroupement par propriétaire, et la
|
||||||
|
préférence est mémorisée comme les autres tris.
|
||||||
|
- Les critères existants (activité Gitea, activité kanban,
|
||||||
|
issues ouvertes, nom) sont inchangés.
|
||||||
|
|
||||||
|
### Corrigé — sens des tris numériques du dashboard
|
||||||
|
|
||||||
|
Le contrôle en navigateur réel a révélé que le tri
|
||||||
|
« Issues ouvertes ↓ » triait en réalité **croissant**
|
||||||
|
depuis son introduction : le comparateur partagé inversait
|
||||||
|
les clés numériques. Les modes numériques (`open` et le
|
||||||
|
nouveau `review`) trient maintenant en soustraction de
|
||||||
|
clés — le plus grand compteur en premier, comme l'annonce
|
||||||
|
le « ↓ ». Vérifié sur les données réelles : Épinglés
|
||||||
|
ordonnés 196, 95, 32, 5, 4 en attente de revue.
|
||||||
|
|
||||||
|
|
||||||
## 0.36.0 — 2026-10-26
|
## 0.36.0 — 2026-10-26
|
||||||
|
|
||||||
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
|
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""Client de distribution du coffre de secrets ObsiGate (Phase 4, issue ObsiGate #22).
|
||||||
|
|
||||||
|
Patron imposé par la spec « Coffre de secrets ObsiGate » §12, appliqué ici
|
||||||
|
sans variante :
|
||||||
|
|
||||||
|
- Kanbante ne stocke QUE des références ``coffre:<slug>/<nom>``, jamais une
|
||||||
|
valeur distribuée ;
|
||||||
|
- à chaque usage, le SERVEUR Kanbante appelle
|
||||||
|
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
|
||||||
|
Kanbante (distinct du jeton MCP de notes et de la session SSO) et un
|
||||||
|
contexte obligatoire, utilise la valeur en mémoire, puis l'oublie ;
|
||||||
|
- aucun cache de valeur, aucune persistance, aucun log de la valeur ;
|
||||||
|
- octroi révoqué -> échec explicite orientant vers ObsiGate, sans repli
|
||||||
|
sur une ancienne valeur ;
|
||||||
|
- ObsiGate indisponible -> échec propre, sans continuer avec une valeur
|
||||||
|
en cache (il ne doit pas y en avoir).
|
||||||
|
|
||||||
|
Configuration (variables d'environnement, jamais versionnées) :
|
||||||
|
- ``KANBANTE_OBSIGATE_URL`` : base d'ObsiGate, ex. ``https://og.dracodev.net``
|
||||||
|
- ``KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`` : jeton du consommateur
|
||||||
|
« kanbante » enregistré dans ObsiGate (jeton révocable, hash + jti
|
||||||
|
côté ObsiGate ; il n'est jamais renvoyé au client ni journalisé).
|
||||||
|
- ``KANBANTE_COFFRE_TIMEOUT`` : délai en secondes (défaut 10).
|
||||||
|
|
||||||
|
Ce module ne dépend que de ``httpx`` (déjà dépendance du projet) et de
|
||||||
|
la bibliothèque standard, pour rester testable avec un faux endpoint
|
||||||
|
(``httpx.MockTransport``) sans instance ObsiGate réelle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("kanbante.coffre")
|
||||||
|
|
||||||
|
PREFIXE_REFERENCE = "coffre:"
|
||||||
|
CHEMIN_DISTRIBUER = "/api/coffres/distribuer"
|
||||||
|
TIMEOUT_DEFAUT = 10.0
|
||||||
|
|
||||||
|
# Messages explicites, orientant vers ObsiGate. Ils ne contiennent jamais
|
||||||
|
# la valeur distribuée, ni le jeton consommateur.
|
||||||
|
_MESSAGES_DETAIL = {
|
||||||
|
"consommateur_invalide": (
|
||||||
|
"Consommateur Kanbante invalide dans ObsiGate : "
|
||||||
|
"vérifier le jeton consommateur Kanbante dans ObsiGate."
|
||||||
|
),
|
||||||
|
"consommateur_revoque": (
|
||||||
|
"Consommateur Kanbante révoqué dans ObsiGate : "
|
||||||
|
"ré-accorder l'accès du consommateur Kanbante dans ObsiGate."
|
||||||
|
),
|
||||||
|
"octroi_absent": (
|
||||||
|
"Aucun octroi actif pour cette référence dans ObsiGate : "
|
||||||
|
"créer l'octroi pour le consommateur Kanbante dans ObsiGate."
|
||||||
|
),
|
||||||
|
"octroi_revoque": (
|
||||||
|
"Accès révoqué dans ObsiGate pour cette référence : "
|
||||||
|
"ré-accorder l'octroi dans ObsiGate, puis réessayer."
|
||||||
|
),
|
||||||
|
"octroi_expire": (
|
||||||
|
"Octroi expiré dans ObsiGate pour cette référence : "
|
||||||
|
"renouveler l'octroi dans ObsiGate, puis réessayer."
|
||||||
|
),
|
||||||
|
"secret_expire": (
|
||||||
|
"Secret expiré dans ObsiGate : "
|
||||||
|
"renouveler le secret dans ObsiGate, puis réessayer."
|
||||||
|
),
|
||||||
|
"reference_invalide": (
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
),
|
||||||
|
"contexte_manquant": (
|
||||||
|
"Contexte de distribution manquant : "
|
||||||
|
"un contexte (type + identifiant) est obligatoire côté ObsiGate."
|
||||||
|
),
|
||||||
|
"kek_absente": "ObsiGate indisponible : clé de chiffrement absente côté ObsiGate.",
|
||||||
|
"crypto_indisponible": "ObsiGate indisponible : chiffrement indisponible côté ObsiGate.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreErreur(Exception):
|
||||||
|
"""Erreur de distribution, sans jamais exposer une valeur secrète."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreReferenceInvalide(CoffreErreur):
|
||||||
|
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreContexteManquant(CoffreErreur):
|
||||||
|
"""Le contexte de distribution (type + identifiant) est obligatoire."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreNonConfigure(CoffreErreur):
|
||||||
|
"""ObsiGate ou le jeton consommateur Kanbante n'est pas configuré."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreAccesRefuse(CoffreErreur):
|
||||||
|
"""ObsiGate a refusé la distribution (octroi/consommateur/secret).
|
||||||
|
|
||||||
|
Aucun repli sur une ancienne valeur n'est autorisé : l'appelant doit
|
||||||
|
échouer proprement avec ce message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreIndisponible(CoffreErreur):
|
||||||
|
"""ObsiGate est injoignable ou a répondu sans valeur utilisable."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CoffreConfig:
|
||||||
|
"""Configuration du consommateur Kanbante côté ObsiGate."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
jeton_consommateur: str
|
||||||
|
timeout: float = TIMEOUT_DEFAUT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_distribuer(self) -> str:
|
||||||
|
return self.base_url.rstrip("/") + CHEMIN_DISTRIBUER
|
||||||
|
|
||||||
|
|
||||||
|
def config_depuis_env() -> CoffreConfig:
|
||||||
|
"""Construit la configuration depuis l'environnement.
|
||||||
|
|
||||||
|
Lève : CoffreNonConfigure si la base ou le jeton est absent.
|
||||||
|
Le jeton n'est jamais journalisé, même en cas d'erreur.
|
||||||
|
"""
|
||||||
|
base_url = os.getenv("KANBANTE_OBSIGATE_URL", "").strip()
|
||||||
|
jeton = os.getenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", "").strip()
|
||||||
|
try:
|
||||||
|
timeout = float(os.getenv("KANBANTE_COFFRE_TIMEOUT", "") or TIMEOUT_DEFAUT)
|
||||||
|
except ValueError:
|
||||||
|
timeout = TIMEOUT_DEFAUT
|
||||||
|
if not base_url or not jeton:
|
||||||
|
raise CoffreNonConfigure(
|
||||||
|
"Consommateur coffre Kanbante non configuré : définir "
|
||||||
|
"KANBANTE_OBSIGATE_URL et KANBANTE_COFFRE_CONSOMMATEUR_TOKEN."
|
||||||
|
)
|
||||||
|
return CoffreConfig(base_url=base_url, jeton_consommateur=jeton, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def est_reference(valeur: str | None) -> bool:
|
||||||
|
"""Vrai si ``valeur`` est une référence coffre (et non une valeur brute)."""
|
||||||
|
if not valeur:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parse_reference(valeur)
|
||||||
|
except CoffreReferenceInvalide:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reference(reference: str) -> tuple[str, str]:
|
||||||
|
"""Valide ``coffre:<slug>/<nom>`` et retourne ``(slug, nom)``.
|
||||||
|
|
||||||
|
Lève : CoffreReferenceInvalide si le format est incorrect.
|
||||||
|
La validation reste volontairement permissive sur les caractères
|
||||||
|
(ObsiGate reste l'autorité de validation) : préfixe, un « / »,
|
||||||
|
slug et nom non vides, aucun espace.
|
||||||
|
"""
|
||||||
|
reference = (reference or "").strip()
|
||||||
|
if not reference.startswith(PREFIXE_REFERENCE):
|
||||||
|
raise CoffreReferenceInvalide(
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
)
|
||||||
|
reste = reference[len(PREFIXE_REFERENCE):]
|
||||||
|
if "/" not in reste or any(c.isspace() for c in reference):
|
||||||
|
raise CoffreReferenceInvalide(
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
)
|
||||||
|
slug, nom = reste.split("/", 1)
|
||||||
|
if not slug or not nom or "/" in nom:
|
||||||
|
raise CoffreReferenceInvalide(
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
)
|
||||||
|
return slug, nom
|
||||||
|
|
||||||
|
|
||||||
|
def _valider_contexte(contexte_type: str, contexte_id: str) -> None:
|
||||||
|
if not (contexte_type or "").strip() or not (contexte_id or "").strip():
|
||||||
|
raise CoffreContexteManquant(
|
||||||
|
"Contexte de distribution manquant : un contexte "
|
||||||
|
"(type + identifiant) est obligatoire."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_erreur(statut: int, detail: str) -> CoffreErreur:
|
||||||
|
"""Mappe une réponse d'erreur ObsiGate vers une erreur typée.
|
||||||
|
|
||||||
|
Les statuts 401/403/410 signalent un refus d'accès (octroi,
|
||||||
|
consommateur ou secret) : jamais de repli. Les autres erreurs
|
||||||
|
(400 de format, 503, réponse sans valeur) sont des indisponibilités
|
||||||
|
ou des erreurs de configuration, également sans repli.
|
||||||
|
"""
|
||||||
|
message = _MESSAGES_DETAIL.get(detail)
|
||||||
|
if statut in (401, 403, 410) or detail in (
|
||||||
|
"octroi_absent",
|
||||||
|
"octroi_revoque",
|
||||||
|
"octroi_expire",
|
||||||
|
"consommateur_invalide",
|
||||||
|
"consommateur_revoque",
|
||||||
|
"secret_expire",
|
||||||
|
):
|
||||||
|
return CoffreAccesRefuse(message or f"Distribution refusée par ObsiGate ({detail}).")
|
||||||
|
if detail == "reference_invalide":
|
||||||
|
return CoffreReferenceInvalide(message or "Référence invalide.")
|
||||||
|
if detail == "contexte_manquant":
|
||||||
|
return CoffreContexteManquant(message or "Contexte manquant.")
|
||||||
|
return CoffreIndisponible(
|
||||||
|
message or f"ObsiGate indisponible (statut {statut})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _traiter_reponse(reponse: httpx.Response, reference: str) -> str:
|
||||||
|
"""Extrait la valeur d'une réponse de distribution, ou lève.
|
||||||
|
|
||||||
|
La valeur extraite n'est jamais journalisée ; seuls la référence,
|
||||||
|
le contexte et le statut le sont.
|
||||||
|
"""
|
||||||
|
if reponse.status_code == 200:
|
||||||
|
try:
|
||||||
|
valeur = reponse.json().get("valeur")
|
||||||
|
except ValueError:
|
||||||
|
valeur = None
|
||||||
|
if isinstance(valeur, str) and valeur:
|
||||||
|
logger.info("distribution coffre ok (reference=%s)", reference)
|
||||||
|
return valeur
|
||||||
|
raise CoffreIndisponible(
|
||||||
|
"ObsiGate a répondu sans valeur distribuable."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
detail = str(reponse.json().get("detail") or "")
|
||||||
|
except ValueError:
|
||||||
|
detail = ""
|
||||||
|
logger.warning(
|
||||||
|
"distribution coffre refusee (reference=%s, statut=%d, detail=%s)",
|
||||||
|
reference,
|
||||||
|
reponse.status_code,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
raise _message_erreur(reponse.status_code, detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _corps_requete(reference: str, contexte_type: str, contexte_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"reference": reference,
|
||||||
|
"contexte_type": contexte_type,
|
||||||
|
"contexte_id": contexte_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def distribuer(
|
||||||
|
reference: str,
|
||||||
|
contexte_type: str,
|
||||||
|
contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Distribue une valeur ObsiGate, en mémoire seulement.
|
||||||
|
|
||||||
|
L'appelant doit utiliser la valeur immédiatement (appel sortant,
|
||||||
|
vérification HMAC) puis la laisser sortir de portée : ce module ne
|
||||||
|
la persiste ni ne la met en cache, et ne la journalise jamais.
|
||||||
|
"""
|
||||||
|
parse_reference(reference)
|
||||||
|
_valider_contexte(contexte_type, contexte_id)
|
||||||
|
cfg = config or config_depuis_env()
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=cfg.timeout, transport=transport) as client:
|
||||||
|
reponse = client.post(
|
||||||
|
cfg.url_distribuer,
|
||||||
|
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||||
|
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise CoffreIndisponible(
|
||||||
|
"ObsiGate injoignable pour la distribution : "
|
||||||
|
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||||
|
) from exc
|
||||||
|
return _traiter_reponse(reponse, reference)
|
||||||
|
|
||||||
|
|
||||||
|
async def distribuer_async(
|
||||||
|
reference: str,
|
||||||
|
contexte_type: str,
|
||||||
|
contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Variante asynchrone de :func:`distribuer` (mêmes garanties)."""
|
||||||
|
parse_reference(reference)
|
||||||
|
_valider_contexte(contexte_type, contexte_id)
|
||||||
|
cfg = config or config_depuis_env()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=cfg.timeout, transport=transport) as client:
|
||||||
|
reponse = await client.post(
|
||||||
|
cfg.url_distribuer,
|
||||||
|
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||||
|
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise CoffreIndisponible(
|
||||||
|
"ObsiGate injoignable pour la distribution : "
|
||||||
|
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||||
|
) from exc
|
||||||
|
return _traiter_reponse(reponse, reference)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def usage_distribue(reference: str, contexte_type: str, contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.BaseTransport | None = None):
|
||||||
|
"""Distribue une valeur pour un seul usage, dans le bloc ``with``.
|
||||||
|
|
||||||
|
La valeur ne doit pas sortir du bloc (ni être stockée, ni journalisée,
|
||||||
|
ni retournée) : à la sortie, l'appelant ne conserve que le résultat
|
||||||
|
de l'usage, jamais la valeur elle-même.
|
||||||
|
"""
|
||||||
|
valeur = distribuer(reference, contexte_type, contexte_id,
|
||||||
|
config=config, transport=transport)
|
||||||
|
try:
|
||||||
|
yield valeur
|
||||||
|
finally:
|
||||||
|
valeur = ""
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def usage_distribue_async(reference: str, contexte_type: str,
|
||||||
|
contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None):
|
||||||
|
"""Variante asynchrone de :func:`usage_distribue`."""
|
||||||
|
valeur = await distribuer_async(reference, contexte_type, contexte_id,
|
||||||
|
config=config, transport=transport)
|
||||||
|
try:
|
||||||
|
yield valeur
|
||||||
|
finally:
|
||||||
|
valeur = ""
|
||||||
+1
-1
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.36.0"
|
APP_VERSION = "0.37.2"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
|||||||
+102
-1
@@ -52,6 +52,7 @@ from app import forecast as forecast_service
|
|||||||
from app import rules as rules_service
|
from app import rules as rules_service
|
||||||
from app import sprint as sprint_service
|
from app import sprint as sprint_service
|
||||||
from app import connections as connections_service
|
from app import connections as connections_service
|
||||||
|
from app import coffre_client
|
||||||
from app import board_columns as bcols
|
from app import board_columns as bcols
|
||||||
from app.briefing import (
|
from app.briefing import (
|
||||||
format_briefing_text,
|
format_briefing_text,
|
||||||
@@ -404,7 +405,52 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
class DracoAlertesCorsMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""CORS Fusion #69, limité à GET /api/events.
|
||||||
|
|
||||||
|
Le panneau Alertes unifiées lit ce journal depuis les autres
|
||||||
|
hôtes dracodev, avec credentials. Seules les origines des
|
||||||
|
applications soeurs (résolues comme les liens de la barre :
|
||||||
|
explicite > hôte de la requête > dérivation > défaut officiel,
|
||||||
|
via resoudre_urls_draco()) reçoivent Access-Control-Allow-Origin.
|
||||||
|
Le preflight OPTIONS ne porte aucune donnée et ne contourne
|
||||||
|
aucune authentification : le GET reste soumis à AuthMiddleware
|
||||||
|
(session ou jeton). Toute autre origine ou route traverse sans
|
||||||
|
en-tête CORS — le navigateur bloque alors la lecture, comme
|
||||||
|
avant. Aucune CSP connect-src n'est à ouvrir ici : Kanbante
|
||||||
|
n'énonce que frame-src (console Spark).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
if request.url.path != "/api/events":
|
||||||
|
return await call_next(request)
|
||||||
|
from app.config import resoudre_urls_draco
|
||||||
|
|
||||||
|
urls = resoudre_urls_draco(request.headers.get("host"))
|
||||||
|
soeurs = {urls.sso_url, urls.flowdeck_url, urls.tiktak_url,
|
||||||
|
urls.public_url}
|
||||||
|
origine = request.headers.get("origin", "")
|
||||||
|
if origine not in soeurs:
|
||||||
|
return await call_next(request)
|
||||||
|
headers = {
|
||||||
|
"Access-Control-Allow-Origin": origine,
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Vary": "Origin",
|
||||||
|
}
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
headers.update({
|
||||||
|
"Access-Control-Allow-Methods": "GET",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Access-Control-Max-Age": "600",
|
||||||
|
})
|
||||||
|
return JSONResponse({}, status_code=200, headers=headers)
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers.update(headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(AuthMiddleware)
|
app.add_middleware(AuthMiddleware)
|
||||||
|
app.add_middleware(DracoAlertesCorsMiddleware)
|
||||||
|
|
||||||
|
|
||||||
def _safe_next(raw: str) -> str:
|
def _safe_next(raw: str) -> str:
|
||||||
@@ -1149,6 +1195,12 @@ async def admin_create_connection(
|
|||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
||||||
)
|
)
|
||||||
|
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||||
|
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
slug = _unique_slug(_slugify(name), "connections")
|
slug = _unique_slug(_slugify(name), "connections")
|
||||||
conn_id = models.create_connection(
|
conn_id = models.create_connection(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -1193,6 +1245,12 @@ async def admin_update_connection(
|
|||||||
if token:
|
if token:
|
||||||
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
||||||
if hook_secret:
|
if hook_secret:
|
||||||
|
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||||
|
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
fields["webhook_secret"] = hook_secret
|
fields["webhook_secret"] = hook_secret
|
||||||
if fields:
|
if fields:
|
||||||
models.update_connection(conn_id, **fields)
|
models.update_connection(conn_id, **fields)
|
||||||
@@ -4879,7 +4937,50 @@ async def _handle_forge_webhook(request: Request, conn_row):
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
raw = await request.body()
|
raw = await request.body()
|
||||||
kind = conn_row["kind"] if conn_row else "gitea"
|
kind = conn_row["kind"] if conn_row else "gitea"
|
||||||
secret = (conn_row or {}).get("webhook_secret") or settings.webhook_secret
|
# Secret webhook : une référence coffre:<slug>/<nom> stockée dans
|
||||||
|
# forge_connections.webhook_secret est distribuée à chaque webhook
|
||||||
|
# (Phase 4, issue ObsiGate #22), utilisée en mémoire pour ce seul
|
||||||
|
# HMAC, puis oubliée. Aucun repli : si la distribution échoue
|
||||||
|
# (octroi révoqué, ObsiGate indisponible ou consommateur non
|
||||||
|
# configuré), le webhook échoue proprement avec un message explicite
|
||||||
|
# orientant vers ObsiGate, sans retomber sur le secret d'environnement
|
||||||
|
# ni sur une ancienne valeur (il n'y en a pas de stockée).
|
||||||
|
# Les valeurs historiques en clair et le repli environnement restent
|
||||||
|
# inchangés (compatibilité) tant qu'aucune référence n'est saisie.
|
||||||
|
stocke = (conn_row or {}).get("webhook_secret") or ""
|
||||||
|
if coffre_client.est_reference(stocke):
|
||||||
|
try:
|
||||||
|
secret = await coffre_client.distribuer_async(
|
||||||
|
stocke,
|
||||||
|
contexte_type="webhook",
|
||||||
|
contexte_id=(conn_row or {}).get("slug") or "gitea",
|
||||||
|
)
|
||||||
|
except coffre_client.CoffreAccesRefuse as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "Accès révoqué ou refusé dans ObsiGate : "
|
||||||
|
"ré-accorder l'octroi du secret webhook pour le "
|
||||||
|
"consommateur Kanbante dans ObsiGate, puis renvoyer "
|
||||||
|
"le webhook.",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except coffre_client.CoffreErreur as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "ObsiGate indisponible ou consommateur Kanbante "
|
||||||
|
"non configuré : impossible de vérifier ce webhook "
|
||||||
|
"sans distribuer son secret. Vérifier ObsiGate, puis "
|
||||||
|
"renvoyer le webhook.",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
secret = stocke or settings.webhook_secret
|
||||||
if secret:
|
if secret:
|
||||||
if kind == "github":
|
if kind == "github":
|
||||||
signature = request.headers.get("x-hub-signature-256", "")
|
signature = request.headers.get("x-hub-signature-256", "")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||||
Snippet vendangé depuis Projets/dracodev-commons v0.1.4
|
Snippet vendangé depuis Projets/dracodev-commons v0.1.6
|
||||||
(ui/barre-apps/) : structure inchangée ; seule adaptation locale
|
(ui/barre-apps/) : structure inchangée ; seule adaptation locale
|
||||||
(0.32.0, dev/prod) = les URL des apps et l'adresse du service
|
(0.32.0, dev/prod) = les URL des apps et l'adresse du service
|
||||||
d'agents sont INJECTÉES par l'hôte depuis app.config
|
d'agents sont INJECTÉES par l'hôte depuis app.config
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
wallboard ni l'écran de connexion, volontaires). #}
|
wallboard ni l'écran de connexion, volontaires). #}
|
||||||
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
|
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
|
||||||
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
|
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ version }}">
|
||||||
<script>document.body.classList.add('has-draco-bar');</script>
|
<script>document.body.classList.add('has-draco-bar');</script>
|
||||||
<header class="draco-bar" data-current="kanbante">
|
<header class="draco-bar" data-current="kanbante">
|
||||||
<div class="draco-bar-zone draco-bar-gauche">
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
@@ -61,8 +62,8 @@
|
|||||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
|
||||||
<span class="draco-avatar" title="Opérateur Kanbante">{{ draco_avatar }}</span>
|
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="Opérateur Kanbante">{{ draco_avatar }}</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||||
@@ -71,10 +72,18 @@
|
|||||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||||
le rail pilote le repli natif de la barre latérale, la palette
|
le rail pilote le repli natif de la barre latérale, la palette
|
||||||
ouvre la recherche & commandes native (Ctrl+K) ; les alertes
|
ouvre la recherche & commandes native (Ctrl+K) ; les alertes
|
||||||
unifiées, la recherche fédérée et Spark arrivent avec les
|
unifiées passent par le panneau draco-alertes.js (Fusion #69,
|
||||||
phases suivantes du programme Fusion. */
|
agrégation à la demande, pas une fédération temps réel). */
|
||||||
window.DRACO_BAR_CONFIG = {
|
window.DRACO_BAR_CONFIG = {
|
||||||
current: "kanbante",
|
current: "kanbante",
|
||||||
|
/* v0.1.6 : user = initiale auth_user ou K (draco_avatar). Sans
|
||||||
|
cfg.user, le nouveau JS dériverait l'initiale du title
|
||||||
|
("Opérateur Kanbante" → "O"), qui est un libellé de rôle et
|
||||||
|
non un nom. Contrepartie documentée : syncAvatar() réécrit
|
||||||
|
aussi le title avec le nom d'affichage, donc l'infobulle
|
||||||
|
devient l'initiale après chargement (le libellé reste dans
|
||||||
|
le HTML servi). */
|
||||||
|
user: "{{ draco_avatar }}",
|
||||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||||
counts: {},
|
counts: {},
|
||||||
onAction: function (act, el) {
|
onAction: function (act, el) {
|
||||||
@@ -88,8 +97,13 @@
|
|||||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
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 === "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 === "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 === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { location.href = "/activity"; } return; }
|
||||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
/* v0.1.6 : le toast placeholder "fédération" est remplacé
|
||||||
|
par le menu par défaut du nouveau JS — retourner false le
|
||||||
|
demande explicitement (un onAction existant garde sinon la
|
||||||
|
main). Contenu : Console agent + applications, URL résolues
|
||||||
|
donc dérivées -dev sur un hôte -dev. */
|
||||||
|
if (act === "menu") { return false; }
|
||||||
if (act === "spark") {
|
if (act === "spark") {
|
||||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||||
@@ -106,3 +120,4 @@
|
|||||||
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
|
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
|
||||||
<script>window.DRACO_PANNEAU_CONFIG = { api: "{{ agent_url }}" };</script>
|
<script>window.DRACO_PANNEAU_CONFIG = { api: "{{ agent_url }}" };</script>
|
||||||
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
|
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
|
||||||
|
<script defer src="/static/js/draco-alertes.js?v={{ version }}"></script>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||||
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
<label>Secret webhook (vide = inchangé, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="admin-actions">
|
<p class="admin-actions">
|
||||||
@@ -68,10 +68,10 @@
|
|||||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||||
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
|
<label>Secret webhook (optionnel, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="coffre:<slug>/<nom>"></label>
|
||||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
<p class="notes-hint">Secret webhook : une référence <code>coffre:<slug>/<nom></code> est stockée telle quelle (jamais la valeur) et distribuée par ObsiGate à chaque webhook, puis oubliée ; octroi révoqué ou ObsiGate indisponible = webhook rejeté, sans repli. Les jetons d'accès forge, eux, restent chiffrés au repos (voir app/forge_crypto.py) et ne basculent pas encore sur une référence — voir docs/coffre-reference-obsigate.md. Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
<label class="dash-tool">Tri
|
<label class="dash-tool">Tri
|
||||||
<select id="dash-sort">
|
<select id="dash-sort">
|
||||||
<option value="default">Par défaut</option>
|
<option value="default">Par défaut</option>
|
||||||
|
<option value="review">🔍 En attente de revue ↓</option>
|
||||||
<option value="updated">Activité Gitea récente</option>
|
<option value="updated">Activité Gitea récente</option>
|
||||||
<option value="activity">Activité kanban récente</option>
|
<option value="activity">Activité kanban récente</option>
|
||||||
<option value="open">Issues ouvertes ↓</option>
|
<option value="open">Issues ouvertes ↓</option>
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
||||||
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
||||||
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
||||||
|
data-review="{{ stats.get(full, {}).get('review', 0) if stats else 0 }}"
|
||||||
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
||||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Kanbante — Références coffre ObsiGate (Phase 4, issue ObsiGate #22)
|
||||||
|
|
||||||
|
Patron « même patron, sans variante » de la spec « Coffre de secrets
|
||||||
|
ObsiGate » §12, appliqué à Kanbante sur la branche
|
||||||
|
`feat/coffre-reference-obsigate` (aucun merge, aucune PR dans ce lot).
|
||||||
|
|
||||||
|
## 1. Inventaire honnête des secrets externes (vérifié dans le code, main 0.37.1)
|
||||||
|
|
||||||
|
| Secret | Stockage actuel | Statut dans ce lot |
|
||||||
|
|---|---|---|
|
||||||
|
| Jeton d'accès forge (`forge_connections.token_encrypted`, admin + profil) | **Chiffré Fernet au repos** (`app/forge_crypto.py`, clé `KANBANTE_MASTER_KEY` ou clé générée en base) ; jamais réaffiché (masque `••••••••`) | **Non migré, à raison** : ce n'est pas un secret en clair. Le basculer sur une référence exigerait une distribution *à chaque appel* forge, car le registre met en cache les clients (`ForgeRegistry`) : distribuer une fois à la construction puis garder le client reviendrait à conserver la valeur en mémoire pour la durée de vie de l'app, ce qui violerait le « utilise, puis oublie » de la spec. Reste à faire dédié (voir §5). |
|
||||||
|
| Secret webhook (`forge_connections.webhook_secret`, admin) | **En clair en base** (colonne `TEXT`, saisie via `/admin/connections`) ; repli environnement `KANBANTE_WEBHOOK_SECRET` | **Migré** : une référence `coffre:<slug>/<nom>` saisie dans ce même champ est stockée telle quelle, distribuée à chaque webhook, puis oubliée (§2). Les valeurs historiques en clair restent acceptées (compatibilité, migration opportuniste), sans jamais être réécrites. |
|
||||||
|
| `GITEA_TOKEN`, `KANBANTE_NTFY_TOKEN`, `KANBANTE_OBSIGATE_MCP_TOKEN`, `KANBANTE_CAPTURE_TOKEN`, `AGENT_EVENEMENTS_CLE`, secrets SSO/session | Variables d'environnement d'exploitation uniquement, jamais persistées par l'app, non surchargeables en base (`OVERRIDABLE_SETTINGS` les exclut explicitement) | **Hors périmètre applicatif** : leur bascule éventuelle vers le coffre relève du déploiement (l'opérateur), pas d'un champ de saisie de Kanbante. Le jeton MCP ObsiGate (`KANBANTE_OBSIGATE_MCP_TOKEN`) n'est **pas** réutilisé ici : le consommateur coffre a son propre jeton, conformément à l'issue. |
|
||||||
|
| Mots de passe utilisateurs, jetons API Kanbante | Hashés (`password_hash`, `token_hash`) | Hors périmètre (pas des secrets externes). |
|
||||||
|
|
||||||
|
Conclusion : Kanbante ne stockait qu'**un** secret externe en clair
|
||||||
|
persisté par l'app — le secret webhook — et c'est celui qui est migré.
|
||||||
|
Le client générique ci-dessous reste disponible pour le premier usage
|
||||||
|
réel suivant (jetons forge, voir §5).
|
||||||
|
|
||||||
|
## 2. Ce qui est implémenté
|
||||||
|
|
||||||
|
### `app/coffre_client.py` (nouveau, dépendance : `httpx` uniquement)
|
||||||
|
|
||||||
|
- `est_reference()` / `parse_reference()` : validation du format
|
||||||
|
`coffre:<slug>/<nom>` (préfixe, un seul `/`, slug et nom non vides).
|
||||||
|
- `config_depuis_env()` : `KANBANTE_OBSIGATE_URL` (base ObsiGate, ex.
|
||||||
|
`https://og.dracodev.net`), `KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`
|
||||||
|
(jeton du consommateur « kanbante » enregistré dans ObsiGate),
|
||||||
|
`KANBANTE_COFFRE_TIMEOUT` (défaut 10 s). Absence de l'un des deux
|
||||||
|
premiers → `CoffreNonConfigure`, échec propre.
|
||||||
|
- `distribuer()` / `distribuer_async()` : `POST /api/coffres/distribuer`
|
||||||
|
avec JSON `{reference, contexte_type, contexte_id}` et
|
||||||
|
`Authorization: Bearer <jeton consommateur>` — contrat vérifié dans le
|
||||||
|
routeur ObsiGate (`backend/routers/coffres.py`, main). La valeur
|
||||||
|
retournée n'est **jamais journalisée, jamais persistée, jamais mise
|
||||||
|
en cache**.
|
||||||
|
- `usage_distribue()` / `usage_distribue_async()` : gestionnaires de
|
||||||
|
contexte « un seul usage » — la valeur ne doit pas sortir du bloc.
|
||||||
|
- Erreurs typées, sans repli, avec messages explicites orientant vers
|
||||||
|
ObsiGate (voir §3). Aucune valeur distribuée n'apparaît dans un
|
||||||
|
message, un log ou une réponse HTTP.
|
||||||
|
|
||||||
|
### Webhook (`app/main.py`, `_handle_forge_webhook`)
|
||||||
|
|
||||||
|
- Si `webhook_secret` stocké est une référence : distribution
|
||||||
|
asynchrone à chaque webhook (`contexte_type="webhook"`,
|
||||||
|
`contexte_id=<slug de la connexion>`), HMAC vérifié avec la valeur en
|
||||||
|
mémoire, puis oubli (fin de portée de la fonction).
|
||||||
|
- Échec de distribution → **aucun repli** sur le secret
|
||||||
|
d'environnement ni sur une ancienne valeur (il n'y en a pas de
|
||||||
|
stockée) : `CoffreAccesRefuse` → HTTP 401 « Accès révoqué ou refusé
|
||||||
|
dans ObsiGate : ré-accorder l'octroi… » ; autre `CoffreErreur`
|
||||||
|
(ObsiGate indisponible, consommateur non configuré) → HTTP 503
|
||||||
|
explicite. Le webhook doit être renvoyé après correction côté
|
||||||
|
ObsiGate.
|
||||||
|
- Si la valeur stockée n'est pas une référence : comportement historique
|
||||||
|
inchangé (secret en clair, puis repli environnement).
|
||||||
|
|
||||||
|
### Formulaire admin (`app/main.py` + `app/templates/admin_connections.html`)
|
||||||
|
|
||||||
|
- Le champ « Secret webhook » accepte désormais une référence
|
||||||
|
`coffre:<slug>/<nom>` (création et modification). Une saisie
|
||||||
|
commençant par `coffre:` mais mal formée est **refusée** (redirection
|
||||||
|
d'erreur), pour ne jamais la stocker comme secret en clair historique
|
||||||
|
par accident.
|
||||||
|
- Le champ « Jeton d'accès » est **inchangé** : une référence saisie
|
||||||
|
là serait aujourd'hui chiffrée Fernet puis rejetée par la forge —
|
||||||
|
c'est volontaire tant que la distribution par appel (§5) n'existe pas.
|
||||||
|
Le bandeau du formulaire le signale.
|
||||||
|
|
||||||
|
Aucun changement de schéma n'a été nécessaire : la référence est
|
||||||
|
stockée dans la colonne `webhook_secret` existante, détectée par son
|
||||||
|
préfixe `coffre:` (un secret historique ne peut pas porter ce préfixe
|
||||||
|
sans avoir été saisi comme tel, et ce cas est désormais validé).
|
||||||
|
|
||||||
|
## 3. Correspondance des erreurs ObsiGate
|
||||||
|
|
||||||
|
| Réponse ObsiGate | Erreur Kanbante | Effet |
|
||||||
|
|---|---|---|
|
||||||
|
| 401 `consommateur_invalide`, 403 `consommateur_revoque` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder » |
|
||||||
|
| 403 `octroi_absent` / `octroi_revoque` / `octroi_expire`, 410 `secret_expire` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder / renouveler » |
|
||||||
|
| 400 `reference_invalide` / `contexte_manquant` | `CoffreReferenceInvalide` / `CoffreContexteManquant` | Webhook 503 (erreur de configuration) |
|
||||||
|
| 503 `kek_absente` / `crypto_indisponible`, timeout, connexion refusée, 200 sans valeur | `CoffreIndisponible` | Webhook 503, renvoyer le webhook plus tard |
|
||||||
|
|
||||||
|
Dans tous les cas : pas de valeur en cache, pas de continuation.
|
||||||
|
|
||||||
|
## 4. Tests
|
||||||
|
|
||||||
|
`tests/test_coffre_client.py` (faux endpoint `httpx.MockTransport`,
|
||||||
|
valeurs fictives `TEST-VALUE-XXXX` uniquement, aucune instance réelle) :
|
||||||
|
|
||||||
|
- validation de références (valides / invalides) ;
|
||||||
|
- succès sync + async, avec vérification de la requête (chemin, Bearer,
|
||||||
|
corps `reference` + contexte) ;
|
||||||
|
- refus (octroi révoqué/expiré/absent, consommateur révoqué/invalide,
|
||||||
|
secret expiré) → `CoffreAccesRefuse` avec message explicite ;
|
||||||
|
- indisponibilité (503, erreur réseau, 200 sans valeur) →
|
||||||
|
`CoffreIndisponible` ;
|
||||||
|
- référence invalide / contexte manquant / configuration absente :
|
||||||
|
aucun appel HTTP ;
|
||||||
|
- webhook de bout en bout (TestClient) : octroi révoqué → 401 explicite
|
||||||
|
sans repli ; distribution réussie → HMAC accepté et ligne en base
|
||||||
|
toujours porteuse de la référence, jamais de la valeur.
|
||||||
|
|
||||||
|
## 5. Restes à faire (exigent validation par Bruno sur instance réelle)
|
||||||
|
|
||||||
|
1. **Validation réelle** : enregistrer le consommateur « kanbante »
|
||||||
|
dans ObsiGate, créer un octroi vers un secret webhook de test,
|
||||||
|
configurer `KANBANTE_OBSIGATE_URL` +
|
||||||
|
`KANBANTE_COFFRE_CONSOMMATEUR_TOKEN` sur l'instance Kanbante, saisir
|
||||||
|
la référence dans `/admin/connections`, envoyer un vrai webhook
|
||||||
|
Gitea, vérifier l'acceptation, puis révoquer l'octroi et vérifier
|
||||||
|
le 401 explicite, puis couper ObsiGate et vérifier le 503.
|
||||||
|
2. **Jetons forge par référence** : distribution à chaque appel dans
|
||||||
|
`GiteaClient`/`GitHubClient` (ou client jetable par requête), sans
|
||||||
|
passer par le cache `ForgeRegistry` ; seulement alors, accepter une
|
||||||
|
référence dans le champ « Jeton d'accès » et documenter la migration
|
||||||
|
des connexions existantes (chiffrées Fernet → références).
|
||||||
|
3. **Migration opportuniste des secrets webhook historiques** : proposer
|
||||||
|
en admin la conversion d'un secret en clair existant vers une
|
||||||
|
référence (l'admin crée le secret + l'octroi dans ObsiGate, puis
|
||||||
|
remplace la valeur par la référence) — aucun outil automatique ne
|
||||||
|
copie une valeur réelle vers ObsiGate dans ce lot.
|
||||||
|
4. **CI complète** : la suite n'a été exécutée que localement dans le
|
||||||
|
sandbox (voir rapport) ; le run CI Gitea de la branche reste à
|
||||||
|
observer avant toute PR.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.36.0"
|
version = "0.37.1"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||||
|
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||||
|
.draco-alertes-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 30000;
|
||||||
|
display: none; align-items: flex-start; justify-content: center;
|
||||||
|
padding: 8vh 16px 16px;
|
||||||
|
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.draco-alertes-box {
|
||||||
|
width: 720px; max-width: 100%;
|
||||||
|
background: var(--bg-primary, #1f1f1f);
|
||||||
|
color: var(--text, #eceee9);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||||
|
.draco-alertes-close {
|
||||||
|
border: 1px solid var(--border, #333); background: transparent;
|
||||||
|
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||||
|
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||||
|
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||||
|
.draco-alertes-item {
|
||||||
|
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||||
|
padding: 9px 12px; margin: 6px 0;
|
||||||
|
}
|
||||||
|
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||||
|
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||||
|
.draco-alertes-title:hover { text-decoration: underline; }
|
||||||
|
.draco-alertes-detail {
|
||||||
|
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||||
|
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||||
|
.draco-alertes-empty a { color: inherit; }
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
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) ;
|
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
|
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||||
|
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
|
||||||
|
que le menu des applications) ; l'avatar est un vrai <button>.
|
||||||
Jetons repris du prototype validé Console v7.2.
|
Jetons repris du prototype validé Console v7.2.
|
||||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
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. */
|
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||||
@@ -42,6 +44,29 @@
|
|||||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||||
}
|
}
|
||||||
.draco-apps-menu[hidden] { display: none; }
|
.draco-apps-menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
|
||||||
|
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
|
||||||
|
.draco-more-menu, .draco-compte-menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 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-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
|
||||||
|
.draco-menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||||
|
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||||
|
background: transparent; color: var(--ink); font: inherit; text-align: left;
|
||||||
|
text-decoration: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||||
|
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
|
||||||
|
.draco-compte-nom {
|
||||||
|
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--line); margin-bottom: 6px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
.draco-apps-item {
|
.draco-apps-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: 10px;
|
padding: 8px 10px; border-radius: 10px;
|
||||||
@@ -122,8 +147,10 @@
|
|||||||
}
|
}
|
||||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||||
.draco-avatar {
|
.draco-avatar {
|
||||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||||
background: #2f6fed; color: #fff; font-weight: 700;
|
display: grid; place-items: center; flex: none;
|
||||||
|
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
function cardKey(card, mode) {
|
function cardKey(card, mode) {
|
||||||
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
||||||
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
||||||
|
if (mode === 'review') return -(parseInt(card.getAttribute('data-review') || '0', 10));
|
||||||
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
||||||
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
||||||
return null; // 'default' : ordre serveur, on ne touche à rien
|
return null; // 'default' : ordre serveur, on ne touche à rien
|
||||||
@@ -52,6 +53,13 @@
|
|||||||
if (isNaN(tb)) return -1;
|
if (isNaN(tb)) return -1;
|
||||||
return tb - ta;
|
return tb - ta;
|
||||||
}
|
}
|
||||||
|
// Modes numériques (open, review) : les clés sont
|
||||||
|
// des compteurs niés (-n) — ka - kb donne donc le
|
||||||
|
// plus grand compteur en premier, comme l'annonce
|
||||||
|
// le « ↓ » des options. (Avant 0.37.0, la
|
||||||
|
// comparaison générique ci-dessous inversait ces
|
||||||
|
// deux tris : ils ressortaient croissants.)
|
||||||
|
if (mode === 'open' || mode === 'review') return ka - kb;
|
||||||
if (ka < kb) return mode === 'name' ? -1 : 1;
|
if (ka < kb) return mode === 'name' ? -1 : 1;
|
||||||
if (ka > kb) return mode === 'name' ? 1 : -1;
|
if (ka > kb) return mode === 'name' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
|
||||||
|
*
|
||||||
|
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||||
|
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||||
|
* interroge les API de notifications réellement exposées par chaque
|
||||||
|
* application, avec les identifiants du navigateur (credentials:
|
||||||
|
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||||
|
* ou non authentifiée affiche son état réel (« Connexion requise »)
|
||||||
|
* avec un lien profond vers sa page native, jamais du vide déguisé.
|
||||||
|
*
|
||||||
|
* Sources réelles (audit #69, 2026-10-28) :
|
||||||
|
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||||
|
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||||
|
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||||
|
* - TikTak : GET {base}/api/notifications
|
||||||
|
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||||
|
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||||
|
* base + "/notifications".
|
||||||
|
* - Kanbante : GET {base}/api/events?limit=100
|
||||||
|
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||||
|
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||||
|
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||||
|
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||||
|
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||||
|
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||||
|
* masqués), pas un journal lisible par l'utilisateur. La section
|
||||||
|
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||||
|
*
|
||||||
|
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||||
|
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||||
|
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||||
|
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||||
|
* 3. href rendu de l'onglet .draco-apptab[data-app] ;
|
||||||
|
* 4. défaut officiel (production).
|
||||||
|
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||||
|
* lit aussi window.DRACO_URLS (injecté par son backend).
|
||||||
|
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||||
|
* que sur un hôte -dev, exactement comme la barre.
|
||||||
|
*
|
||||||
|
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||||
|
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||||
|
* pour la pastille (repli : mise à jour directe du badge).
|
||||||
|
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||||
|
* avec une valeur venue du serveur.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var DEFAULT_URLS = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/"
|
||||||
|
};
|
||||||
|
var DEV_URLS = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/"
|
||||||
|
};
|
||||||
|
var APP_LABELS = {
|
||||||
|
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante"
|
||||||
|
};
|
||||||
|
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||||
|
var EVENT_LABELS = {
|
||||||
|
created: "créée", moved: "déplacée", closed: "fermée",
|
||||||
|
reopened: "rouverte", commented: "commentée"
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDevHost(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
var h = String(hostname).toLowerCase();
|
||||||
|
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||||
|
var first = h.split(".")[0];
|
||||||
|
return first.length > 4 && first.slice(-4) === "-dev";
|
||||||
|
}
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u || typeof u !== "string") return null;
|
||||||
|
if (u.indexOf("{{") !== -1) return null;
|
||||||
|
if (!u.replace(/\s/g, "")) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||||
|
}
|
||||||
|
function joinUrl(base, path) {
|
||||||
|
var b = withSlash(base);
|
||||||
|
if (!path) return b;
|
||||||
|
if (/^https?:\/\//.test(path)) return path;
|
||||||
|
return b + String(path).replace(/^\//, "");
|
||||||
|
}
|
||||||
|
function parseDate(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||||
|
? String(s).replace(" ", "T") + "Z" : s);
|
||||||
|
return isNaN(t.getTime()) ? null : t;
|
||||||
|
}
|
||||||
|
function fmtDate(s) {
|
||||||
|
var t = parseDate(s);
|
||||||
|
if (!t) return s ? String(s).slice(0, 16) : "";
|
||||||
|
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||||
|
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
|
||||||
|
function resolveUrls(env) {
|
||||||
|
env = env || {};
|
||||||
|
var hostname = env.hostname;
|
||||||
|
if (hostname === undefined) {
|
||||||
|
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||||
|
}
|
||||||
|
var dev = isDevHost(hostname);
|
||||||
|
var urls = {};
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
var u = cleanUrl(env.barUrls && env.barUrls[app])
|
||||||
|
|| cleanUrl(env.configUrls && env.configUrls[app]);
|
||||||
|
if (!u && dev) u = DEV_URLS[app] || null;
|
||||||
|
if (!u) {
|
||||||
|
var href = null;
|
||||||
|
if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
|
||||||
|
else if (typeof document !== "undefined" && document.querySelector) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
href = tab ? tab.getAttribute("href") : null;
|
||||||
|
}
|
||||||
|
u = cleanUrl(href);
|
||||||
|
}
|
||||||
|
if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
|
||||||
|
if (!u) {
|
||||||
|
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||||
|
try {
|
||||||
|
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
if (!u) u = DEFAULT_URLS[app];
|
||||||
|
urls[app] = withSlash(u);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
function browserEnv() {
|
||||||
|
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||||
|
var bar = global.DRACO_BAR || {};
|
||||||
|
var tabs = {};
|
||||||
|
try {
|
||||||
|
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||||
|
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
return {
|
||||||
|
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||||
|
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFlowdeck(base, payload) {
|
||||||
|
var list = (payload && payload.notifications) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
return {
|
||||||
|
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.message || "", date: n.created_at || "",
|
||||||
|
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||||
|
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeTiktak(base, payload) {
|
||||||
|
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
return {
|
||||||
|
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.body || "", date: n.created_at || "",
|
||||||
|
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||||
|
kind: n.kind || "",
|
||||||
|
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeKanbante(base, payload) {
|
||||||
|
var list = (payload && payload.events) || [];
|
||||||
|
return list.map(function (e) {
|
||||||
|
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||||
|
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||||
|
return {
|
||||||
|
app: "kanbante", id: e.id,
|
||||||
|
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||||
|
detail: detail, date: e.created_at || "",
|
||||||
|
active: false, state: label, kind: e.event_type || "",
|
||||||
|
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* Actives d'abord, puis date décroissante (sans date en dernier). */
|
||||||
|
function sortAlerts(list) {
|
||||||
|
return list.slice().sort(function (a, b) {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||||
|
var da = parseDate(a.date), db = parseDate(b.date);
|
||||||
|
if (da && db) return db - da;
|
||||||
|
if (da) return -1;
|
||||||
|
if (db) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function countActive(list) {
|
||||||
|
var n = 0;
|
||||||
|
list.forEach(function (a) { if (a.active) n++; });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Rendu (navigateur seulement) ---- */
|
||||||
|
var panel = null, listEl = null, badgeTimer = null;
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var d = document.createElement(tag);
|
||||||
|
if (cls) d.className = cls;
|
||||||
|
if (text !== undefined && text !== null) d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function ensurePanel() {
|
||||||
|
if (panel) return panel;
|
||||||
|
var ov = el("div", "draco-alertes-overlay");
|
||||||
|
ov.setAttribute("role", "dialog");
|
||||||
|
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||||
|
var box = el("div", "draco-alertes-box");
|
||||||
|
var head = el("div", "draco-alertes-head");
|
||||||
|
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||||
|
var note = el("p", "draco-alertes-note",
|
||||||
|
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||||
|
"les alertes sont relues dans chaque application à l'ouverture.");
|
||||||
|
var close = el("button", "draco-alertes-close", "✕");
|
||||||
|
close.setAttribute("aria-label", "Fermer");
|
||||||
|
close.addEventListener("click", closePanel);
|
||||||
|
head.appendChild(close);
|
||||||
|
box.appendChild(head);
|
||||||
|
box.appendChild(note);
|
||||||
|
listEl = el("div", "draco-alertes-list");
|
||||||
|
box.appendChild(listEl);
|
||||||
|
ov.appendChild(box);
|
||||||
|
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key === "Escape") closePanel();
|
||||||
|
});
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
panel = ov;
|
||||||
|
return ov;
|
||||||
|
}
|
||||||
|
function closePanel() { if (panel) panel.style.display = "none"; }
|
||||||
|
|
||||||
|
function renderSection(title, items, fallback) {
|
||||||
|
var sec = el("section", "draco-alertes-section");
|
||||||
|
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
|
||||||
|
if (!items.length) {
|
||||||
|
sec.appendChild(el("p", "draco-alertes-empty", fallback));
|
||||||
|
return sec;
|
||||||
|
}
|
||||||
|
items.forEach(function (a) {
|
||||||
|
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||||
|
var main = el("div", "draco-alertes-main");
|
||||||
|
var link = el("a", "draco-alertes-title", a.title);
|
||||||
|
link.href = a.url;
|
||||||
|
main.appendChild(link);
|
||||||
|
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||||
|
var meta = el("div", "draco-alertes-meta",
|
||||||
|
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
|
||||||
|
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
|
||||||
|
main.appendChild(meta);
|
||||||
|
row.appendChild(main);
|
||||||
|
sec.appendChild(row);
|
||||||
|
});
|
||||||
|
return sec;
|
||||||
|
}
|
||||||
|
function render(results, urls) {
|
||||||
|
ensurePanel();
|
||||||
|
listEl.textContent = "";
|
||||||
|
var all = [];
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
|
||||||
|
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
|
||||||
|
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
var items = actives.filter(function (a) { return a.app === app; });
|
||||||
|
if (results[app] && results[app].error) {
|
||||||
|
var sec = renderSection(APP_LABELS[app], [], results[app].error);
|
||||||
|
listEl.appendChild(sec);
|
||||||
|
} else if (app === "obsigate") {
|
||||||
|
listEl.appendChild(renderSection(APP_LABELS[app], [],
|
||||||
|
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||||
|
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||||
|
} else if (items.length) {
|
||||||
|
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!actives.length) {
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
|
||||||
|
}
|
||||||
|
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
|
||||||
|
if (app === "obsigate") {
|
||||||
|
var link = el("div", "draco-alertes-empty");
|
||||||
|
var a = el("a", "", "Ouvrir ObsiGate");
|
||||||
|
a.href = urls.obsigate;
|
||||||
|
link.appendChild(a);
|
||||||
|
var sec = el("section", "draco-alertes-section");
|
||||||
|
sec.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||||
|
sec.appendChild(link);
|
||||||
|
listEl.appendChild(sec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = history.filter(function (x) { return x.app === app; });
|
||||||
|
listEl.appendChild(renderSection(APP_LABELS[app], items,
|
||||||
|
"Aucun historique disponible pour cette application."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson(url, timeoutMs) {
|
||||||
|
var ctrl = null, timer = null;
|
||||||
|
try {
|
||||||
|
if (typeof AbortController !== "undefined") {
|
||||||
|
ctrl = new AbortController();
|
||||||
|
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||||
|
}
|
||||||
|
} catch (e) { ctrl = null; }
|
||||||
|
var opts = { credentials: "include" };
|
||||||
|
if (ctrl) opts.signal = ctrl.signal;
|
||||||
|
return fetch(url, opts).then(function (r) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = new Error("HTTP " + r.status);
|
||||||
|
err.status = r.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadAll() {
|
||||||
|
var urls = resolveUrls(browserEnv());
|
||||||
|
var jobs = {
|
||||||
|
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||||
|
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||||
|
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||||
|
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||||
|
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||||
|
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||||
|
};
|
||||||
|
var keys = Object.keys(jobs);
|
||||||
|
return Promise.all(keys.map(function (k) {
|
||||||
|
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||||
|
var msg = (e && e.status === 401)
|
||||||
|
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||||
|
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||||
|
return [k, { items: [], error: msg }];
|
||||||
|
});
|
||||||
|
})).then(function (pairs) {
|
||||||
|
var results = { obsigate: { items: [] } };
|
||||||
|
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||||
|
return { urls: urls, results: results };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setBadge(n) {
|
||||||
|
try {
|
||||||
|
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||||
|
global.DRACO_BAR.setCounts({ alertes: n });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* repli ci-dessous */ }
|
||||||
|
try {
|
||||||
|
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||||
|
b.textContent = n;
|
||||||
|
b.hidden = !(n > 0);
|
||||||
|
});
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
function refreshBadge() {
|
||||||
|
return loadAll().then(function (r) {
|
||||||
|
var all = [];
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
setBadge(countActive(all));
|
||||||
|
return countActive(all);
|
||||||
|
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||||
|
}
|
||||||
|
function openPanel() {
|
||||||
|
ensurePanel();
|
||||||
|
panel.style.display = "flex";
|
||||||
|
listEl.textContent = "";
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||||
|
loadAll().then(function (r) {
|
||||||
|
render(r.results, r.urls);
|
||||||
|
var all = [];
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
setBadge(countActive(all));
|
||||||
|
}).catch(function () {
|
||||||
|
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = {
|
||||||
|
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||||
|
_test: {
|
||||||
|
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||||
|
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||||
|
sortAlerts: sortAlerts, countActive: countActive,
|
||||||
|
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
global.DRACO_ALERTES = api;
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||||
|
|
||||||
|
/* Pastille au chargement (après les scripts defer de la barre). */
|
||||||
|
if (typeof document !== "undefined" && document.addEventListener) {
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (badgeTimer) clearTimeout(badgeTimer);
|
||||||
|
badgeTimer = setTimeout(refreshBadge, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
+403
-40
@@ -1,5 +1,10 @@
|
|||||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||||
* v0.1.3 : menu des applications ouvert par la marque ✦).
|
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
|
||||||
|
* v0.1.5 : noms publics par configuration — aucun nom public d'un
|
||||||
|
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||||
|
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||||
|
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||||
|
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
|
||||||
*
|
*
|
||||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
* 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
|
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||||
@@ -10,19 +15,158 @@
|
|||||||
* current: "tiktak", // app courante
|
* current: "tiktak", // app courante
|
||||||
* domain: "pro", // domaine actif
|
* domain: "pro", // domaine actif
|
||||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||||
|
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||||
|
* // ou { display_name: "Bruno", username: "bruno" }
|
||||||
|
* urls: { // bases des applications
|
||||||
|
* obsigate: "https://og.dracodev.net/",
|
||||||
|
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
* tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
* kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
* agent: "https://agent.dracodev.net/",
|
||||||
|
* spark: "https://spark.dracodev.net/"
|
||||||
|
* },
|
||||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||||
* } // qu'elle ne connaît pas
|
* } // qu'elle ne connaît pas
|
||||||
|
*
|
||||||
|
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
|
||||||
|
* résolution, dans l'ordre STRICT, pour chaque app :
|
||||||
|
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
|
||||||
|
* serveur ou client) depuis SA configuration d'environnement —
|
||||||
|
* explicite, donc prioritaire, même sur un hôte -dev ;
|
||||||
|
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
|
||||||
|
* (première étiquette se terminant par -dev : tiktak-dev,
|
||||||
|
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
|
||||||
|
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
|
||||||
|
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
|
||||||
|
* les noms SANS -dev faute de configuration explicite ;
|
||||||
|
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
|
||||||
|
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
|
||||||
|
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
|
||||||
|
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
|
||||||
|
* partout (config injectée comme href rendu) et ne peut pas être exposé.
|
||||||
|
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
||||||
|
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
|
||||||
|
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
|
||||||
|
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
|
||||||
|
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
|
||||||
|
* Spark (panneau-agent), qui suit le même contrat d'injection.
|
||||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
* ouvre le menu des applications, construit depuis les onglets ;
|
||||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
* c'est le moyen de changer d'app en mobile, où les onglets sont
|
||||||
|
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
|
||||||
|
*
|
||||||
|
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
|
||||||
|
* connu à gérer l'action "menu" via onAction (il déclenche son propre
|
||||||
|
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
|
||||||
|
* pas et le bouton restait mort (relais vers un onAction absent, puis
|
||||||
|
* événement draco:bar sans écouteur). Comportement désormais :
|
||||||
|
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
|
||||||
|
* la console agent (URL agent résolue, donc dérivée -dev sur un
|
||||||
|
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
|
||||||
|
* par clic extérieur, Échap ou choix d'un lien ;
|
||||||
|
* - avec onAction : l'hôte garde la main (comportement historique,
|
||||||
|
* zéro régression pour ObsiGate) ; il peut demander explicitement
|
||||||
|
* le menu par défaut en retournant false pour l'action "menu".
|
||||||
|
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
|
||||||
|
* un « B » codé en dur dans le fragment, est un vrai <button>
|
||||||
|
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
|
||||||
|
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
|
||||||
|
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
|
||||||
|
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
|
||||||
|
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
|
||||||
|
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
|
||||||
|
* mais le menu affiche l'identité même sans gestionnaire hôte.
|
||||||
|
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
|
||||||
|
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||||
|
|
||||||
|
/* Défauts OFFICIELS (production) — les seuls noms publics présents
|
||||||
|
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
|
||||||
|
var DEFAULT_URLS = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
agent: "https://agent.dracodev.net/",
|
||||||
|
spark: "https://spark.dracodev.net/"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
|
||||||
|
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
|
||||||
|
* volontairement absent : aucun hôte spark-dev connu, aucune URL
|
||||||
|
* inventée ; spark retombe sur href rendu puis défaut prod. */
|
||||||
|
var DEV_URLS = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||||
|
agent: "https://agent-dev.dracodev.net/"
|
||||||
|
};
|
||||||
|
|
||||||
|
var APP_LABELS = {
|
||||||
|
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
|
||||||
|
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
|
||||||
|
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
|
||||||
|
* ne déclenche rien). Insensible à la casse. */
|
||||||
|
function isDevHost(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
var h = String(hostname).toLowerCase();
|
||||||
|
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||||
|
var first = h.split(".")[0];
|
||||||
|
return first.length > 4 && first.slice(-4) === "-dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u || typeof u !== "string") return null;
|
||||||
|
if (u.indexOf("{{") !== -1) return null;
|
||||||
|
if (!u.replace(/\s/g, "")) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrls() {
|
||||||
|
var host = "";
|
||||||
|
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
|
||||||
|
var dev = isDevHost(host);
|
||||||
|
var urls = {};
|
||||||
|
Object.keys(DEFAULT_URLS).forEach(function (app) {
|
||||||
|
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
|
||||||
|
var u = cleanUrl(cfg.urls && cfg.urls[app]);
|
||||||
|
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
|
||||||
|
if (!u && dev) u = DEV_URLS[app] || null;
|
||||||
|
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
|
||||||
|
if (!u) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
var href = tab ? tab.getAttribute("href") : null;
|
||||||
|
u = cleanUrl(href);
|
||||||
|
}
|
||||||
|
/* 4. Défaut officiel (production). */
|
||||||
|
if (!u) u = DEFAULT_URLS[app];
|
||||||
|
urls[app] = withSlash(u);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
function applyUrls(urls) {
|
||||||
|
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||||
|
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var resolvedUrls = resolveUrls();
|
||||||
|
|
||||||
function setCounts(counts) {
|
function setCounts(counts) {
|
||||||
Object.keys(counts || {}).forEach(function (k) {
|
Object.keys(counts || {}).forEach(function (k) {
|
||||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||||
@@ -43,26 +187,122 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||||
* Construit depuis les onglets présents dans la barre : mêmes
|
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||||
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||||
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||||
* choix d'une application (la navigation suit son cours). */
|
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
|
||||||
|
function userName() {
|
||||||
|
var u = cfg.user;
|
||||||
|
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
|
||||||
|
if (u && typeof u === "object") {
|
||||||
|
var n = u.display_name || u.name || u.username;
|
||||||
|
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function titleName() {
|
||||||
|
var av = document.querySelector(".draco-avatar");
|
||||||
|
var t = av ? av.getAttribute("title") : null;
|
||||||
|
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function displayName() { return userName() || titleName(); }
|
||||||
|
function syncAvatar() {
|
||||||
|
var av = document.querySelector(".draco-avatar");
|
||||||
|
if (!av) return;
|
||||||
|
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
|
||||||
|
* sans data-act) : le rendre déclencheur du menu compte. */
|
||||||
|
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
|
||||||
|
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
|
||||||
|
var name = displayName();
|
||||||
|
if (name) {
|
||||||
|
av.textContent = name.charAt(0).toUpperCase();
|
||||||
|
av.setAttribute("title", name);
|
||||||
|
av.setAttribute("aria-label", "Compte : " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
|
||||||
|
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||||
|
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||||
|
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||||
|
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
|
||||||
|
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||||
var bar = document.querySelector(".draco-bar");
|
var bar = document.querySelector(".draco-bar");
|
||||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||||
var menu = null;
|
var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
|
||||||
|
var avatarBtn = document.querySelector(".draco-avatar");
|
||||||
|
var appsMenu = null, moreMenu = null, compteMenu = null;
|
||||||
|
|
||||||
function buildMenu() {
|
function setExpanded(btn, open) {
|
||||||
if (!bar || !brand || menu) return;
|
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
menu = document.createElement("div");
|
}
|
||||||
menu.className = "draco-apps-menu";
|
function refreshMenuHrefs(menu) {
|
||||||
menu.hidden = true;
|
if (!menu) return;
|
||||||
menu.setAttribute("role", "menu");
|
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||||
menu.setAttribute("aria-label", "Applications");
|
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function togglePopup(menu, btn, force) {
|
||||||
|
if (!menu) return;
|
||||||
|
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||||
|
menu.hidden = !open;
|
||||||
|
setExpanded(btn, open);
|
||||||
|
}
|
||||||
|
function closeAllPopups() {
|
||||||
|
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||||
|
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
|
||||||
|
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||||
|
}
|
||||||
|
function tabLabel(app) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
|
||||||
|
return fort ? fort.textContent : (APP_LABELS[app] || app);
|
||||||
|
}
|
||||||
|
function tabRole(app) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||||
|
return petit ? petit.textContent : "";
|
||||||
|
}
|
||||||
|
function menuLink(app, nom, role) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.className = "draco-apps-item draco-menu-item";
|
||||||
|
a.href = resolvedUrls[app] || "#";
|
||||||
|
a.dataset.app = app;
|
||||||
|
a.setAttribute("role", "menuitem");
|
||||||
|
var txt = document.createElement("span");
|
||||||
|
txt.className = "draco-apps-item-txt";
|
||||||
|
var n = document.createElement("span");
|
||||||
|
n.className = "draco-apps-item-nom";
|
||||||
|
n.textContent = nom;
|
||||||
|
txt.appendChild(n);
|
||||||
|
if (role) {
|
||||||
|
var r = document.createElement("span");
|
||||||
|
r.className = "draco-apps-item-role";
|
||||||
|
r.textContent = role;
|
||||||
|
txt.appendChild(r);
|
||||||
|
}
|
||||||
|
a.appendChild(txt);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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). */
|
||||||
|
function buildAppsMenu() {
|
||||||
|
if (!bar || !brand || appsMenu) return;
|
||||||
|
appsMenu = document.createElement("div");
|
||||||
|
appsMenu.className = "draco-apps-menu";
|
||||||
|
appsMenu.hidden = true;
|
||||||
|
appsMenu.setAttribute("role", "menu");
|
||||||
|
appsMenu.setAttribute("aria-label", "Applications");
|
||||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.className = "draco-apps-item";
|
a.className = "draco-apps-item";
|
||||||
a.href = t.getAttribute("href");
|
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||||
a.dataset.app = t.dataset.app;
|
a.dataset.app = t.dataset.app;
|
||||||
a.setAttribute("role", "menuitem");
|
a.setAttribute("role", "menuitem");
|
||||||
var ico = t.querySelector(".draco-apptab-ico");
|
var ico = t.querySelector(".draco-apptab-ico");
|
||||||
@@ -84,44 +324,167 @@
|
|||||||
a.appendChild(txt);
|
a.appendChild(txt);
|
||||||
var badge = t.querySelector(".draco-badge");
|
var badge = t.querySelector(".draco-badge");
|
||||||
if (badge) a.appendChild(badge.cloneNode(true));
|
if (badge) a.appendChild(badge.cloneNode(true));
|
||||||
menu.appendChild(a);
|
appsMenu.appendChild(a);
|
||||||
});
|
});
|
||||||
bar.appendChild(menu);
|
bar.appendChild(appsMenu);
|
||||||
|
}
|
||||||
|
function toggleAppsMenu(force) {
|
||||||
|
buildAppsMenu();
|
||||||
|
if (!appsMenu) return;
|
||||||
|
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||||
|
togglePopup(appsMenu, brand, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMenu(force) {
|
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
|
||||||
buildMenu();
|
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
|
||||||
if (!menu) return;
|
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
|
||||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
|
||||||
menu.hidden = !open;
|
function buildMoreMenu() {
|
||||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
if (!bar || !moreBtn || moreMenu) return;
|
||||||
|
moreMenu = document.createElement("div");
|
||||||
|
moreMenu.className = "draco-more-menu";
|
||||||
|
moreMenu.hidden = true;
|
||||||
|
moreMenu.setAttribute("role", "menu");
|
||||||
|
moreMenu.setAttribute("aria-label", "Plus d'actions");
|
||||||
|
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
|
||||||
|
var sep = document.createElement("div");
|
||||||
|
sep.className = "draco-menu-sep";
|
||||||
|
sep.setAttribute("aria-hidden", "true");
|
||||||
|
moreMenu.appendChild(sep);
|
||||||
|
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
|
||||||
|
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
|
||||||
|
});
|
||||||
|
bar.appendChild(moreMenu);
|
||||||
|
}
|
||||||
|
function toggleMoreMenu(force) {
|
||||||
|
buildMoreMenu();
|
||||||
|
if (!moreMenu) return;
|
||||||
|
if (force !== false) refreshMenuHrefs(moreMenu);
|
||||||
|
togglePopup(moreMenu, moreBtn, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
|
||||||
|
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
|
||||||
|
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
|
||||||
|
* remplit l'avatar depuis /api/auth/me après chargement). */
|
||||||
|
function buildCompteMenu() {
|
||||||
|
if (!bar || !avatarBtn || compteMenu) return;
|
||||||
|
compteMenu = document.createElement("div");
|
||||||
|
compteMenu.className = "draco-compte-menu";
|
||||||
|
compteMenu.hidden = true;
|
||||||
|
compteMenu.setAttribute("role", "menu");
|
||||||
|
compteMenu.setAttribute("aria-label", "Compte");
|
||||||
|
var head = document.createElement("div");
|
||||||
|
head.className = "draco-compte-nom";
|
||||||
|
compteMenu.appendChild(head);
|
||||||
|
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "draco-menu-item";
|
||||||
|
b.setAttribute("data-compte-act", pair[0]);
|
||||||
|
b.setAttribute("role", "menuitem");
|
||||||
|
b.textContent = pair[1];
|
||||||
|
compteMenu.appendChild(b);
|
||||||
|
});
|
||||||
|
bar.appendChild(compteMenu);
|
||||||
|
}
|
||||||
|
function refreshCompteMenu() {
|
||||||
|
if (!compteMenu) return;
|
||||||
|
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||||
|
var name = displayName();
|
||||||
|
if (head) head.textContent = name || "Compte";
|
||||||
|
}
|
||||||
|
function toggleCompteMenu(force) {
|
||||||
|
buildCompteMenu();
|
||||||
|
if (!compteMenu) return;
|
||||||
|
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||||
|
togglePopup(compteMenu, avatarBtn, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||||
|
* événement draco:bar (contrat historique des actions relayées). */
|
||||||
|
function relay(act, el, ev) {
|
||||||
|
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||||
|
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("click", function (ev) {
|
document.addEventListener("click", function (ev) {
|
||||||
var el = ev.target.closest("[data-act]");
|
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||||
if (el && el.dataset.act === "apps") {
|
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||||
ev.preventDefault();
|
if (compteItem) {
|
||||||
toggleMenu();
|
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||||
|
toggleCompteMenu(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||||
toggleMenu(false);
|
if (el && el.dataset.act === "apps") {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (moreMenu) toggleMoreMenu(false);
|
||||||
|
if (compteMenu) toggleCompteMenu(false);
|
||||||
|
toggleAppsMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el && el.dataset.act === "menu") {
|
||||||
|
if (appsMenu) toggleAppsMenu(false);
|
||||||
|
if (compteMenu) toggleCompteMenu(false);
|
||||||
|
/* Hôte prioritaire : il garde la main, sauf s'il demande
|
||||||
|
* explicitement le menu par défaut en retournant false.
|
||||||
|
* Sans onAction, le menu par défaut s'ouvre (il ne restait
|
||||||
|
* auparavant qu'un événement draco:bar sans écouteur). */
|
||||||
|
if (typeof cfg.onAction === "function") {
|
||||||
|
var handled = cfg.onAction("menu", el, ev);
|
||||||
|
if (handled === false) toggleMoreMenu();
|
||||||
|
} else {
|
||||||
|
toggleMoreMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el && el.dataset.act === "compte") {
|
||||||
|
if (appsMenu) toggleAppsMenu(false);
|
||||||
|
if (moreMenu) toggleMoreMenu(false);
|
||||||
|
toggleCompteMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||||
|
* sur son déclencheur déjà traité ci-dessus). */
|
||||||
|
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||||
|
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
|
||||||
|
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||||
|
closeAllPopups();
|
||||||
}
|
}
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
var act = el.dataset.act;
|
var act = el.dataset.act;
|
||||||
if (act === "domaine") setDomain(el.dataset.dom);
|
if (act === "domaine") setDomain(el.dataset.dom);
|
||||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
relay(act, el, ev);
|
||||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", function (ev) {
|
document.addEventListener("keydown", function (ev) {
|
||||||
if (ev.key === "Escape") toggleMenu(false);
|
if (ev.key === "Escape") closeAllPopups();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
* setUrls(urls) fusionne de nouvelles bases (même contrat que
|
||||||
|
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
|
||||||
|
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
|
||||||
|
* les bases résolues (défauts inclus), pour le panneau Spark et tout
|
||||||
|
* autre consommateur de l'hôte. */
|
||||||
|
window.DRACO_BAR = {
|
||||||
|
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
|
||||||
|
urls: resolvedUrls,
|
||||||
|
setUrls: function (urls) {
|
||||||
|
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||||
|
resolvedUrls = resolveUrls();
|
||||||
|
window.DRACO_BAR.urls = resolvedUrls;
|
||||||
|
applyUrls(resolvedUrls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
buildMenu();
|
applyUrls(resolvedUrls);
|
||||||
|
buildAppsMenu();
|
||||||
|
buildMoreMenu();
|
||||||
|
buildCompteMenu();
|
||||||
|
syncAvatar();
|
||||||
|
refreshCompteMenu();
|
||||||
if (cfg.current) setCurrent(cfg.current);
|
if (cfg.current) setCurrent(cfg.current);
|
||||||
if (cfg.domain) setDomain(cfg.domain);
|
if (cfg.domain) setDomain(cfg.domain);
|
||||||
if (cfg.counts) setCounts(cfg.counts);
|
if (cfg.counts) setCounts(cfg.counts);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
var etatConnu = false;
|
var etatConnu = false;
|
||||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
var titreEl = null, tiroirEl = null;
|
var titreEl = null, tiroirEl = null;
|
||||||
|
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||||
|
|
||||||
function domaine() {
|
function domaine() {
|
||||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||||
@@ -268,6 +269,7 @@
|
|||||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||||
' <button type="submit">Envoyer</button></form>' +
|
' <button type="submit">Envoyer</button></form>' +
|
||||||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||||
|
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
@@ -276,6 +278,7 @@
|
|||||||
sendBtn = root.querySelector("button[type=submit]");
|
sendBtn = root.querySelector("button[type=submit]");
|
||||||
titreEl = root.querySelector(".d-titre");
|
titreEl = root.querySelector(".d-titre");
|
||||||
tiroirEl = root.querySelector(".draco-discussions");
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
|
versionEl = root.querySelector(".draco-panneau-version");
|
||||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||||
@@ -330,6 +333,35 @@
|
|||||||
titreEl.title = titreEl.textContent;
|
titreEl.title = titreEl.textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Version du service : lue en direct dans la santé de
|
||||||
|
* l'agent (GET /healthz → champ « version », la même source que
|
||||||
|
* la console) à l'origine que le panneau connaît déjà. Cache de
|
||||||
|
* session (5 min), rafraîchie à l'ouverture du panneau, au même
|
||||||
|
* rythme que la vérification d'identité. Jamais de valeur codée
|
||||||
|
* en dur : source injoignable ou réponse illisible = la ligne
|
||||||
|
* reste masquée — pas de fausse version, pas d'erreur visible. */
|
||||||
|
function afficherVersion() {
|
||||||
|
if (!versionEl) return;
|
||||||
|
if (versionConnue) {
|
||||||
|
versionEl.textContent = "Service d'agents v" + versionConnue;
|
||||||
|
versionEl.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function chargerVersion() {
|
||||||
|
if (!API || !versionEl) return;
|
||||||
|
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
|
||||||
|
fetch(API + "/healthz")
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
|
||||||
|
versionConnue = d.version;
|
||||||
|
versionMaj = Date.now();
|
||||||
|
afficherVersion();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* service injoignable : rien d'affiché */ });
|
||||||
|
}
|
||||||
|
|
||||||
function verifierIdentite() {
|
function verifierIdentite() {
|
||||||
if (etatConnu) return;
|
if (etatConnu) return;
|
||||||
if (!API) {
|
if (!API) {
|
||||||
@@ -812,6 +844,7 @@
|
|||||||
root.classList.add("open");
|
root.classList.add("open");
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
verifierIdentite();
|
verifierIdentite();
|
||||||
|
chargerVersion();
|
||||||
setTimeout(function () { inputEl.focus(); }, 300);
|
setTimeout(function () { inputEl.focus(); }, 300);
|
||||||
}
|
}
|
||||||
function fermer() { if (root) root.classList.remove("open"); }
|
function fermer() { if (root) root.classList.remove("open"); }
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Tests unitaires (node:test) du module partagé draco-alertes.js.
|
||||||
|
// Exécutés dans la sandbox : node --test draco-alertes.test.js
|
||||||
|
// Aucune donnée inventée : les payloads reprennent la forme exacte
|
||||||
|
// des API réelles (Flowdeck /api/notifications, TikTak
|
||||||
|
// /api/notifications, Kanbante /api/events).
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const t = require("../static/js/draco-alertes.js");
|
||||||
|
|
||||||
|
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
|
||||||
|
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||||
|
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||||
|
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||||
|
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||||
|
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||||
|
const explicit = t.resolveUrls({
|
||||||
|
hostname: "tiktak-dev.dracodev.net",
|
||||||
|
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||||
|
});
|
||||||
|
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||||
|
const href = t.resolveUrls({
|
||||||
|
hostname: "example.org",
|
||||||
|
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/" },
|
||||||
|
});
|
||||||
|
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||||
|
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||||
|
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||||
|
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||||
|
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||||
|
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||||
|
notifications: [
|
||||||
|
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||||
|
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||||
|
],
|
||||||
|
unread: 1,
|
||||||
|
});
|
||||||
|
assert.equal(items.length, 2);
|
||||||
|
assert.equal(items[0].active, true);
|
||||||
|
assert.equal(items[0].state, "active");
|
||||||
|
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
|
||||||
|
assert.equal(items[1].active, false);
|
||||||
|
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeTiktak: lien tache, sinon page notifications", () => {
|
||||||
|
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||||
|
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||||
|
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||||
|
]);
|
||||||
|
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
|
||||||
|
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
|
||||||
|
assert.equal(items[0].active, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
|
||||||
|
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||||
|
events: [
|
||||||
|
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||||
|
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(items.length, 2);
|
||||||
|
assert.ok(items.every((i) => i.active === false));
|
||||||
|
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
|
||||||
|
assert.match(items[0].title, /#69/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||||
|
const list = [
|
||||||
|
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
|
||||||
|
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
|
||||||
|
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
|
||||||
|
{ app: "kanbante", active: false, date: "" },
|
||||||
|
];
|
||||||
|
const sorted = t.sortAlerts(list);
|
||||||
|
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
|
||||||
|
assert.equal(sorted[0].active, true);
|
||||||
|
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
|
||||||
|
assert.equal(sorted[3].date, "");
|
||||||
|
assert.equal(t.countActive(list), 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Tests du client de distribution coffre ObsiGate (Phase 4, issue ObsiGate #22).
|
||||||
|
|
||||||
|
Aucune instance ObsiGate réelle n'est contactée : le « faux endpoint » est
|
||||||
|
un ``httpx.MockTransport`` local, et toutes les valeurs distribuées dans
|
||||||
|
ces tests sont fictives (``TEST-VALUE-XXXX``), jamais des secrets réels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import models
|
||||||
|
from app.coffre_client import (
|
||||||
|
CoffreAccesRefuse,
|
||||||
|
CoffreConfig,
|
||||||
|
CoffreContexteManquant,
|
||||||
|
CoffreIndisponible,
|
||||||
|
CoffreNonConfigure,
|
||||||
|
CoffreReferenceInvalide,
|
||||||
|
distribuer,
|
||||||
|
distribuer_async,
|
||||||
|
est_reference,
|
||||||
|
parse_reference,
|
||||||
|
usage_distribue,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuration fictive, réservée aux tests.
|
||||||
|
CONFIG_TEST = CoffreConfig(
|
||||||
|
base_url="https://obsigate.test",
|
||||||
|
jeton_consommateur="TEST-CONSUMER-TOKEN",
|
||||||
|
)
|
||||||
|
REFERENCE_TEST = "coffre:TEST-SLUG/TEST-NOM"
|
||||||
|
VALEUR_TEST = "TEST-VALUE-XXXX"
|
||||||
|
|
||||||
|
|
||||||
|
def _transport_faux(statut=200, corps=None, verifier=None):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if verifier is not None:
|
||||||
|
verifier(request)
|
||||||
|
return httpx.Response(statut, json=corps if corps is not None else {})
|
||||||
|
|
||||||
|
return httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _verifier_requete(request: httpx.Request):
|
||||||
|
assert request.url.path == "/api/coffres/distribuer"
|
||||||
|
assert request.headers["Authorization"] == "Bearer TEST-CONSUMER-TOKEN"
|
||||||
|
corps = json.loads(request.content.decode())
|
||||||
|
assert corps["reference"] == REFERENCE_TEST
|
||||||
|
assert corps["contexte_type"] == "webhook"
|
||||||
|
assert corps["contexte_id"] == "gitea"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reference, attendu",
|
||||||
|
[
|
||||||
|
("coffre:personnel/gitea", True),
|
||||||
|
("coffre:TEST-SLUG/TEST-NOM", True),
|
||||||
|
("coffre:a/b/c", False), # un seul « / » après le préfixe
|
||||||
|
("coffre:/nom", False),
|
||||||
|
("coffre:slug/", False),
|
||||||
|
("coffre:slug", False),
|
||||||
|
("gitea", False),
|
||||||
|
("", False),
|
||||||
|
(None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reference_validation(reference, attendu):
|
||||||
|
assert est_reference(reference) is attendu
|
||||||
|
if attendu:
|
||||||
|
assert parse_reference(reference)[0]
|
||||||
|
else:
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
parse_reference(reference or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_succes_sync():
|
||||||
|
transport = _transport_faux(
|
||||||
|
200,
|
||||||
|
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||||
|
verifier=_verifier_requete,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
== VALEUR_TEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_distribuer_succes_async():
|
||||||
|
transport = _transport_faux(
|
||||||
|
200,
|
||||||
|
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||||
|
verifier=_verifier_requete,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await distribuer_async(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport
|
||||||
|
)
|
||||||
|
== VALEUR_TEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"statut, detail, message_attendu",
|
||||||
|
[
|
||||||
|
(403, "octroi_revoque", "ré-accorder"),
|
||||||
|
(403, "octroi_expire", "renouveler"),
|
||||||
|
(403, "octroi_absent", "octroi"),
|
||||||
|
(401, "consommateur_revoque", "ré-accorder"),
|
||||||
|
(401, "consommateur_invalide", "invalide"),
|
||||||
|
(410, "secret_expire", "expiré"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_distribuer_refus_sans_repli(statut, detail, message_attendu):
|
||||||
|
transport = _transport_faux(statut, {"detail": detail})
|
||||||
|
with pytest.raises(CoffreAccesRefuse) as excinfo:
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
assert message_attendu in str(excinfo.value)
|
||||||
|
assert VALEUR_TEST not in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_indisponible_503_et_reseau():
|
||||||
|
transport = _transport_faux(503, {"detail": "kek_absente"})
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
|
||||||
|
def _panne(request: httpx.Request):
|
||||||
|
raise httpx.ConnectError("faux ObsiGate injoignable", request=request)
|
||||||
|
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=httpx.MockTransport(_panne),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_sans_valeur_malgre_200():
|
||||||
|
transport = _transport_faux(200, {"reference": REFERENCE_TEST})
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_reference_invalide_sans_appel():
|
||||||
|
def _interdit(request: httpx.Request):
|
||||||
|
raise AssertionError("aucun appel HTTP ne doit partir")
|
||||||
|
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
distribuer("valeur-brute", "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=httpx.MockTransport(_interdit))
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_contexte_manquant_sans_appel():
|
||||||
|
with pytest.raises(CoffreContexteManquant):
|
||||||
|
distribuer(REFERENCE_TEST, "", "gitea", config=CONFIG_TEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_non_configure(monkeypatch):
|
||||||
|
monkeypatch.delenv("KANBANTE_OBSIGATE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", raising=False)
|
||||||
|
with pytest.raises(CoffreNonConfigure):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea")
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_distribue_ne_conserve_que_le_resultat():
|
||||||
|
transport = _transport_faux(200, {"valeur": VALEUR_TEST})
|
||||||
|
# Le bloc ne sert qu'à l'usage : ici, dériver un HMAC, comme le ferait
|
||||||
|
# la vérification d'un webhook. Seul le résultat de l'usage est
|
||||||
|
# conservé après le bloc, jamais la valeur distribuée.
|
||||||
|
with usage_distribue(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=transport,
|
||||||
|
) as valeur:
|
||||||
|
signature = hmac.new(valeur.encode(), b"corps-test", hashlib.sha256).hexdigest()
|
||||||
|
assert len(signature) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_reference_octroi_revoque_sans_repli(client, monkeypatch):
|
||||||
|
"""Référence webhook + octroi révoqué -> 401 explicite, sans repli."""
|
||||||
|
models.create_connection(
|
||||||
|
"coffre-webhook", "gitea", "Coffre webhook", "https://gitea.test",
|
||||||
|
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _refuse(*args, **kwargs):
|
||||||
|
raise CoffreAccesRefuse("Accès révoqué dans ObsiGate pour cette référence.")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.coffre_client, "distribuer_async", _refuse)
|
||||||
|
reponse = client.post(
|
||||||
|
"/webhooks/coffre-webhook",
|
||||||
|
content=b"{}",
|
||||||
|
headers={"x-gitea-signature": "0" * 64},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 401
|
||||||
|
assert "ObsiGate" in reponse.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_reference_distribuee_puis_oubliee(client, monkeypatch):
|
||||||
|
"""Référence webhook distribuée -> HMAC vérifié, valeur non persistée."""
|
||||||
|
models.create_connection(
|
||||||
|
"coffre-webhook-ok", "gitea", "Coffre webhook OK", "https://gitea.test",
|
||||||
|
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _distribue(reference, contexte_type, contexte_id):
|
||||||
|
assert reference == REFERENCE_TEST
|
||||||
|
assert contexte_type == "webhook"
|
||||||
|
assert contexte_id == "coffre-webhook-ok"
|
||||||
|
return VALEUR_TEST
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.coffre_client, "distribuer_async", _distribue)
|
||||||
|
corps = b"{}"
|
||||||
|
signature = hmac.new(VALEUR_TEST.encode(), corps, hashlib.sha256).hexdigest()
|
||||||
|
reponse = client.post(
|
||||||
|
"/webhooks/coffre-webhook-ok",
|
||||||
|
content=corps,
|
||||||
|
headers={"x-gitea-signature": signature},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
# La valeur distribuée n'a jamais été persistée : la ligne stocke
|
||||||
|
# toujours la référence, jamais la valeur fictive.
|
||||||
|
ligne = models.get_connection_by_slug("coffre-webhook-ok")
|
||||||
|
assert ligne["webhook_secret"] == REFERENCE_TEST
|
||||||
|
assert VALEUR_TEST not in (ligne["webhook_secret"] or "")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Fusion #69 — Alertes unifiées (chantier 2, Kanbante).
|
||||||
|
|
||||||
|
Kanbante n'a pas de table de notifications : sa source réelle est le
|
||||||
|
journal d'événements, déjà exposé par GET /api/events (aucun nouvel
|
||||||
|
endpoint). Kanbante ne porte aucun état lu/non lu : ses événements
|
||||||
|
sont donc tous de l'historique et ne sont jamais comptés comme
|
||||||
|
actifs par le panneau. Couverts ici : placeholder disparu, module
|
||||||
|
servi, CORS limité à /api/events pour les seules origines soeurs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SOEUR = "https://flowdeck.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_barre_alertes_sans_placeholder_et_module_servi(client):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Alertes unifiées : arrive avec la fédération" not in r.text
|
||||||
|
assert "draco-alertes.js" in r.text
|
||||||
|
assert "draco-alertes.css" in r.text
|
||||||
|
js = client.get("/static/js/draco-alertes.js").text
|
||||||
|
assert "DRACO_ALERTES" in js
|
||||||
|
assert "api/events" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_forme(client):
|
||||||
|
r = client.get("/api/events")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert isinstance(body["events"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_events_soeur_preflight_get_et_tiers(client):
|
||||||
|
r = client.options("/api/events",
|
||||||
|
headers={"Origin": SOEUR,
|
||||||
|
"Access-Control-Request-Method": "GET"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("access-control-allow-origin") == SOEUR
|
||||||
|
assert r.headers.get("access-control-allow-credentials") == "true"
|
||||||
|
r2 = client.get("/api/events", headers={"Origin": SOEUR})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.headers.get("access-control-allow-origin") == SOEUR
|
||||||
|
r3 = client.options("/api/events",
|
||||||
|
headers={"Origin": "https://evil.example",
|
||||||
|
"Access-Control-Request-Method": "GET"})
|
||||||
|
assert "access-control-allow-origin" not in r3.headers
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
|
||||||
|
les projets (section Épinglés incluse) s'ordonnent par le nombre
|
||||||
|
de cartes dans la colonne de validation."""
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_sort_offers_review_mode(client):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_cards_carry_review_count(client):
|
||||||
|
# Aucune carte en révision au départ.
|
||||||
|
r = client.get("/")
|
||||||
|
assert 'data-review="0"' in r.text
|
||||||
|
# Une carte passe en Révision : le compteur de SA carte monte.
|
||||||
|
models.move_issue("Projets/demo", 5, "review")
|
||||||
|
models.move_issue("Projets/demo", 1, "review")
|
||||||
|
r = client.get("/")
|
||||||
|
assert 'data-review="2"' in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_js_knows_review_mode(client):
|
||||||
|
# Le moteur de tri partagé est servi tel quel : le mode
|
||||||
|
# 'review' doit y figurer (tri décroissant sur data-review).
|
||||||
|
r = client.get("/static/js/dashboard.js")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "mode === 'review'" in r.text
|
||||||
|
assert "data-review" in r.text
|
||||||
|
# Le sens : les modes numeriques trient en ka - kb (cles
|
||||||
|
# niees = plus grand compteur d\'abord). Sans cette
|
||||||
|
# branche, le comparateur generique inversait le tri :
|
||||||
|
# bug attrape en navigateur reel sur le tri historique.
|
||||||
|
assert "return ka - kb" in r.text
|
||||||
Reference in New Issue
Block a user