Compare commits

..
13 Commits
Author SHA1 Message Date
Spark 3964323abd Panneau Spark V3 : console en surimpression pleine page (0.29.0)
CI / test (push) Successful in 2m11s
2026-10-23 04:10:35 +00:00
Spark 4230ac1052 Panneau Spark : bouton « Console ↗ » vers la console du service (0.28.1)
CI / test (push) Successful in 2m6s
2026-10-22 21:18:49 +00:00
Spark 3809462cc7 0.28.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
CI / test (push) Successful in 2m1s
- app/agent_journal.py : enveloppe ACC-0.2 signée HMAC-SHA256
  (clé AGENT_EVENEMENTS_CLE, URL AGENT_EVENEMENTS_URL), envoi
  en arrière-plan, timeout 2 s, inactif sans clé, échecs avalés
- main.py : _emettre_changement_colonne accolée aux log_event de
  déplacement (interface, étiquettes forge, webhook, réconciliation,
  PR fusionnée, branche) — kanbante.card.moved pour tout changement
  de colonne, kanbante.card.review_requested à l'entrée en Révision
- config : AGENT_EVENEMENTS_URL/_CLE documentés et lus à l'appel
- 247 tests verts (5 nouveaux : signature revérifiable, inactivité
  sans clé, moved, review_requested, panne du récepteur)
2026-10-22 18:40:00 +00:00
Spark 868496891d 0.27.0 — Panneau Spark v2 (Lot 1)
CI / test (push) Canceled after 0s
- draco-panneau vendangé v2 (identique dans les 4 apps) : Markdown
  rendu par le service 0.10.0, largeur réglable, thème aligné,
  discussions façon Muse (principale + parallèles)
- 242 tests verts
2026-10-22 11:54:31 -04:00
Spark 4a947747f2 0.26.0 — panneau Spark intégré (barre commune, assets vendangés)
CI / test (push) Failing after 1m56s
- static/js/draco-panneau.js + static/css/draco-panneau.css (copie
  du panneau partagé : conversation, approbations, missions J5)
- _draco_bar.html : action spark → DRACO_PANNEAU.toggle()
2026-10-22 03:39:34 +00:00
Spark 5ef6c21756 feat(0.26.0): écritures MCP minimales — create_issue + add_comment gardés par jeton kbt_ 2026-10-22 03:31:19 +00:00
Spark 13182f5d53 0.25.0 — Jetons d'API personnels (kbt_) pour les surfaces machine
CI / test (push) Successful in 1m56s
Depuis 0.24.0, /mcp exige session ou Basic : le service d'agents s'y
faisait refuser en 401 (vu en production, panneau Spark du 2026-10-21).

- Table api_tokens (empreinte SHA-256 seule), CRUD dans Mon profil
- Bearer honoré par le middleware sur /mcp et /api/ uniquement —
  jamais l'interface web ; révocation et désactivation immédiates
- 235 tests verts (3 nouveaux : cycle de vie, compte désactivé,
  handshake MCP par jeton quand l'auth est active)
2026-10-21 21:38:16 -04:00
Spark 2381ae0b35 fix(0.24.2): base sombre pour tous les champs de saisie (#29)
CI / test (push) Successful in 1m54s
2026-10-21 21:31:57 -04:00
bruno 7b6e72e6ce chore: APP_VERSION 0.24.1
CI / test (push) Successful in 1m53s
2026-10-21 15:31:43 -04:00
bruno 6e31122c46 fix: cryptography manquant dans dependencies (forge_crypto.py) — crash ModuleNotFoundError au démarrage v0.24.0
CI / test (push) Successful in 1m53s
2026-10-21 15:26:40 -04:00
Spark d51ef5a970 feat(0.24.0): utilisateurs, administration, espaces, paramètres, archives (P2-P4)
CI / test (push) Successful in 1m53s
- Comptes en base (PBKDF2), rôles, désactivation qui tue la
  session, audit ; secours environnement pour l'opérateur ;
  liaison draco par utilisateur ; page /profile.
- Espaces de travail : connexions, membres, sélection de
  projets ; board/notes/épingles/journal scopés par espace ;
  sélecteur d'espace, badge connexion.
- Panneaux /admin (utilisateurs, connexions, espaces,
  paramètres — l'environnement amorce, le panneau gouverne).
- /archives : archive locale réversible, archivage forge
  confirmé, purge de l'état local, export Markdown, file
  « à archiver ? » avec archivage en lot.
- Correctif session : les comptes membres résolus par la base
  (la session n'est plus limitée à l'opérateur d'environnement).
- 231 tests verts (12 nouveaux au palier 19).
2026-10-21 14:48:30 -04:00
Spark 735d7cdfd0 feat(forge): fondation multi-forges — connexions chiffrées, registre, client GitHub (P0/P1)
- Tables forge_connections/users/workspaces (+ membres, projets,
  états, audit) ; migration des anciennes bases avec portées
  (workspace_id, connection_id) par défaut (1, 1).
- Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en
  base), jamais réaffichés.
- ForgeRegistry : connexion héritée créée depuis l'environnement,
  espace Principal, instantanés préfixés par connexion.
- GitHubClient complet (Bearer, pagination Link, étiquettes par
  nom, PATCH archived, branches via git/refs).
- Paramètres surchargeables en base (résolveur config, P3).
2026-10-21 14:48:29 -04:00
Spark f1a05279b6 fix: barre commune v0.1.4 (palier 900 px) + assets versionnés anti-cache (#28)
CI / test (push) Successful in 1m39s
2026-10-21 12:47:15 -04:00
37 changed files with 6743 additions and 334 deletions
+222
View File
@@ -1,5 +1,227 @@
# Changelog
## 0.29.0 — 2026-10-23
### Modifié — panneau Spark V3 : la console en surimpression pleine page
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
plus en nouvel onglet : la console web du service d'agents
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
page**, en surimpression qui recouvre tout l'écran avec la même
animation d'ouverture que la barre latérale (panneau vendangé
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
styles de la surimpression sont injectés par le panneau lui-même).
La barre de la surimpression offre les deux sorties : « Mode
sidebar » referme la console et rouvre l'agent en barre latérale,
« Fermer l'agent » ferme la surimpression et le panneau. La
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
passent comme avant à la console. Côté service d'agents : la
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
## 0.28.1 — 2026-10-22
### Ajouté — panneau Spark : bouton « Console ↗ »
Bouton « Console ↗ » dans la barre des discussions du panneau
Spark (demande de Bruno) : ouvre la console web du service
d'agents (https://agent.dracodev.net/) dans un nouvel onglet,
sur la discussion actuellement affichée (`?disc=<id>`, URL
construite au clic) et avec l'app d'origine (`?app=kanbante`,
déduite du sous-domaine). Panneau recopié octet-identique dans
les 4 apps (TikTak 0.59.1, Flowdeck 7.80.1, Kanbante 0.28.1,
ObsiGate 2.76.1). **Redéploiement Docker requis** pour l'activer.
## 0.28.0 — 2026-10-22
### Ajouté — émission d'événements vers le journal du service d'agents (Lot 2)
Kanbante émet désormais ses changements de colonne au journal
d'activité cross-app du service d'agents (contrat ACC-0.2) :
`kanbante.card.moved` pour tout déplacement réel d'une carte —
glisser-déposer de l'interface, adoption d'une étiquette
`kanban:*` posée dans la forge, fermeture/réouverture venue de la
forge (webhook ou réconciliation à l'affichage), fusion de PR,
création de branche — et `kanbante.card.review_requested` quand
une carte entre en colonne Révision. Enveloppe signée HMAC-SHA256
avec la clé propre à Kanbante (`AGENT_EVENEMENTS_CLE`, URL
réglable par `AGENT_EVENEMENTS_URL`) ; émission au mieux, en
arrière-plan : inactive sans clé, et une panne du service
d'agents n'affecte jamais un déplacement. Aucun changement de
comportement visible. Redéploiement Docker requis.
## 0.27.0 — 2026-10-22
### Ajouté — panneau Spark v2 (Lot 1)
Le panneau vendangé passe en v2, à l'identique dans les 4 apps :
réponses de l'agent en **Markdown rendu** (HTML du service
d'agents 0.10.0, sanitizé côté serveur, rapports de missions
compris), **largeur réglable** persistée, **thème clair/sombre**
aligné sur l'app hôte ou le système, et **discussions façon
Muse** : discussion principale perpétuelle + parallèles
créées/archivées/supprimées depuis le tiroir du panneau.
Redéploiement Docker requis.
## 0.26.0 — 2026-10-21
### Ajouté — panneau Spark intégré
Le bouton Spark de la barre commune ouvre désormais le panneau de
l'agent commun (conversation, Aujourd'hui, approbations, missions),
comme dans TikTak et Flowdeck : assets `draco-panneau.js/css`
vendangés à l'identique, action `spark` branchée sur
`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste
de la 0.26.0).
### Ajouté — écritures MCP minimales et sûres (service d'agents)
Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service
d'agents (jeton `kbt_` posé en production) peut désormais créer
des cartes et commenter, et rien d'autre : aucune suppression,
aucun changement d'état.
- **`create_issue`** — arguments : `owner` (chaîne, requis),
`repo` (chaîne, requis), `title` (chaîne, requis), `body`
(chaîne, optionnel), `column` (optionnel, une des colonnes du
board, `backlog` par défaut). Réutilise le service de création
existant (`_do_capture` → `create_issue` du client de forge,
Gitea ou GitHub selon la connexion qui détient le dépôt) : la
carte est placée sur le board et journalisée (source `mcp`).
Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`,
`column`.
- **`add_comment`** — arguments : `owner`, `repo`, `number`
(entier), `body` — tous requis. Réutilise `create_comment` du
client de forge (mêmes validations que l'API web : corps non
vide, 10 000 caractères max) et journalise l'événement
`commented` (source `mcp`). Réponse JSON : `id`, `body`,
`html_url`, `repo_full_name`, `number`.
- **Garde inchangée** : les deux outils passent par le même
`/mcp` et le même middleware que les lectures — session,
Basic ou `Authorization: Bearer kbt_…` ; un jeton ne
déverrouille toujours pas l'interface web, et sa révocation
bloque aussitôt les écritures. Erreurs métier au format
existant du serveur (résultat `isError` avec le message).
- `GET /mcp` déclare désormais `read_only: false` et la liste
`write_tools` (honnêteté de la découverte).
- **Redéploiement Docker de Kanbante à la charge de Bruno**
pour activer cette version en production.
## 0.25.0 — 2026-10-21
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
Depuis la 0.24.0, tout Kanbante exige une authentification (session
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
Kanbante injoignable »). Il manquait une identité machine propre.
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
l'empreinte SHA-256 est conservée ; création, liste et révocation
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
seule fois à la création.
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
web. Révocation et désactivation du compte ont effet immédiat ;
`last_used_at` journalise l'usage.
- Usage visé : créer un jeton « service-agents » et le poser en
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
## 0.24.2 — 2026-10-21
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
(Administration), la zone « Sélection de projets » (Espaces) et
les champs « Valeur du panneau » (Paramètres) s'affichaient en
**blanc**. Il n'existait aucune règle de base pour les champs de
saisie : seuls les champs couverts par un contexte précis
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
select seulement) étaient sombres — les textarea et tout champ
hors de ces contextes gardaient le fond du navigateur.
Correctif dans `style.css` : une **règle de base unique en
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
`--text`, bordure `--border` — pour `input`, `textarea` et
`select`, qui comble les vides sans jamais écraser les règles de
contexte existantes ; plus `color-scheme: dark` pour les
contrôles natifs. Styles calculés vérifiés sur les trois pages
(fond identique aux champs de référence, palette de commandes
inchangée).
## 0.24.0 — 2026-10-21
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées
selon les recommandations). Aucune configuration obligatoire :
au premier démarrage, la connexion Gitea historique est créée
depuis l'environnement, l'espace **Principal** la contient, et
le compte d'environnement devient l'administrateur initial
(mot de passe haché en base). Migration automatique des bases
existantes (portées (1, 1) partout).
- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou
**GitHub**, nouveau client complet : dépôts, issues, étiquettes
par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en
base, jamais réaffiché (masque ••••). Registre de clients,
instantanés par connexion. Webhooks par connexion
(`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du
webhook historique.
- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles
admin/membre, activation/désactivation (une session d'un
compte désactivé meurt aussitôt), journal d'audit admin.
Connexion par la base ; les identifiants d'environnement
restent un **secours** pour le compte opérateur. Liaison
**draco par utilisateur** (le pont SSO lie et ouvre la
session du bon compte ; sémantique mono-utilisateur 0.23
conservée tant qu'il n'y a qu'un compte). Page **/profile** :
infos, mot de passe, déliaison draco, connexions personnelles.
- **Espaces de travail (P3)** — l'unité de présentation :
connexions attachées, membres (admin d'espace / membre /
lecteur), sélection explicite de projets optionnelle.
**Board, notes, épingles du jour, journal : scopés par
espace** (le même projet peut être rangé différemment dans
deux espaces). Sélecteur d'espace dans l'en-tête (visible
dès qu'on a accès à plusieurs espaces), badge de connexion
sur les projets quand l'espace en a plusieurs. Panneau
**Paramètres** : l'environnement amorce, le panneau
gouverne — surcharges en base pour les réglages courants
(fenêtre d'activité, TTL, seuils zombies/âge, horaires des
rituels, seuil d'archivage…), provenance affichée.
- **Archives (P4)** — page **/archives** : archive **locale**
(masque le projet dans l'espace, forge intacte, réversible),
archivage **sur la forge** (confirmé, journalisé), purge de
l'état local de l'espace, export du récapitulatif Markdown,
file **« À archiver ? »** (inactifs depuis
`archive_suggest_days`, 180 j par défaut) avec archivage
local en lot. Les archives sortent du tableau de bord ;
l'onglet Archivés y gagne un lien « Gérer ».
### Limites assumées de cette version
- Les rituels d'arrière-plan (briefing, digest, règles,
serveur MCP) travaillent sur la portée historique
(connexion principale, espace Principal).
- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de
chiffrement est générée une fois et gardée en base.
## 0.23.3 — 2026-10-21
### Correctifs — barre commune v0.1.4 (retours de Bruno)
- **Assets de la barre versionnés** (`?v=` dans l'adresse) : sans
ça, le navigateur — surtout la webview de l'app bureau ObsiGate —
gardait l'ancien JavaScript et le menu du ✦ ne s'ouvrait pas,
alors que le serveur servait déjà le bon fichier.
- **Palier mobile élargi à 900 px** (commons v0.1.4) : entre 700
et 900 px, la barre complète ne tenait pas alors que Kanbante
est déjà en mise en page mobile — d'où le look différent des
autres apps sur la capture de Bruno. Décalage du cadre aligné.
## 0.23.2 — 2026-10-21
### Ajouté — menu des applications (barre commune v0.1.3)
+13 -7
View File
@@ -33,7 +33,7 @@
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
- 📝 **Notes** — bloc-notes par projet
@@ -212,15 +212,17 @@ kanbante/
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` |
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
## Brancher un client MCP
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
les lectures d'abord). Configuration type (transport HTTP) :
Kanbante expose un serveur MCP : lectures d'abord, plus deux
écritures minimales et sûres pour les agents — créer une carte et
commenter (aucune suppression, aucun changement d'état).
Configuration type (transport HTTP) :
```json
{
@@ -233,9 +235,13 @@ les lectures d'abord). Configuration type (transport HTTP) :
```
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
Kanbante est active, le client doit envoyer l'en-tête
`Authorization` correspondant.
`search_issues`, `get_activity`, `get_metrics`, `create_issue`
(`owner`, `repo`, `title`, `body` et `column` optionnels),
`add_comment` (`owner`, `repo`, `number`, `body`). Si
l'authentification Kanbante est active, le client doit envoyer
l'en-tête `Authorization` correspondant : Basic, ou un jeton
d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) —
honoré uniquement sur `/mcp` et `/api/`, jamais sur le web.
## Activer le temps réel (webhook Gitea)
+68
View File
@@ -0,0 +1,68 @@
"""User accounts: password hashing and bootstrap (0.24.0, P2).
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a
per-user salt; the stored string carries its parameters
("pbkdf2$<iterations>$<salt_b64>$<hash_b64>") so the cost can
be raised later without invalidating existing hashes.
"""
import base64
import hashlib
import hmac
import secrets
from app import models
_ITERATIONS = 260_000
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, _ITERATIONS
)
return "pbkdf2${}${}${}".format(
_ITERATIONS,
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
def verify_password(password: str, stored: str) -> bool:
try:
scheme, iterations, salt_b64, hash_b64 = stored.split("$")
if scheme != "pbkdf2":
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, int(iterations)
)
return hmac.compare_digest(digest, expected)
except Exception:
return False
def ensure_bootstrap_admin() -> dict | None:
"""Create the first admin from the env credentials.
Runs when the users table is empty and KANBANTE_AUTH_USER /
_PASSWORD are configured (decision D6): Bruno's existing
login becomes the initial database admin, with no lockout.
The env path keeps working as an emergency fallback either
way (see app.main auth).
"""
from app.config import get_settings
if models.count_users() > 0:
return None
settings = get_settings()
if not settings.auth_enabled:
return None
user_id = models.create_user(
settings.auth_user,
hash_password(settings.auth_password),
role="admin",
display_name=settings.auth_user,
)
return models.get_user(user_id)
+119
View File
@@ -0,0 +1,119 @@
"""Émission d'événements vers le journal du service d'agents (Lot 2).
Kanbante émet ses changements de colonne au journal d'activité
cross-app du service d'agents, au contrat ACC-0.2 : enveloppe JSON
``{id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}}`` signée HMAC-SHA256 du corps brut
(en-tête ``X-Draco-Signature``), avec la clé propre à Kanbante
(``AGENT_EVENEMENTS_CLE`` ; le service la connaît sous le nom
d'app « kanbante » dans ``AGENT_EVENEMENTS_CLES``).
Inactif tant que la clé n'est pas posée. Jamais bloquant, jamais
d'échec remonté au métier : l'envoi part dans un fil d'arrière-plan,
timeout court, exceptions avalées avec un avertissement au journal
local. L'appel sortant suit le patron de la maison (httpx, voir
``briefing.send_ntfy``).
"""
import hashlib
import hmac
import json
import logging
import threading
import uuid
from datetime import datetime, timezone
import httpx
from app.config import get_settings
logger = logging.getLogger("kanbante")
SOURCE_APP = "kanbante"
DELAI_S = 2.0
def actif() -> bool:
"""True quand l'émission est configurée (clé propre posée)."""
return bool(get_settings().agent_evenements_cle)
def construire_enveloppe(
type_: str,
ref: str,
label: str,
url: str,
domain: str = "pro",
actor: str = "user",
) -> dict:
"""L'enveloppe ACC-0.2 : une référence et un libellé, jamais le
contenu de l'objet."""
return {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"actor": actor,
"domain": domain,
"source_app": SOURCE_APP,
"object": {"ref": ref, "label": label, "url": url},
}
def signer(corps: bytes, cle: str) -> str:
"""Signature ACC-0.2 : HMAC-SHA256 (hexadécimal) du corps brut."""
return hmac.new(cle.encode(), corps, hashlib.sha256).hexdigest()
def _poster(url: str, corps: bytes, signature: str) -> bool:
"""POST l'enveloppe signée ; False (et un avertissement) sur
tout échec — le récepteur en panne ne doit rien casser ici."""
try:
reponse = httpx.post(
url,
content=corps,
headers={
"Content-Type": "application/json",
"X-Draco-Signature": signature,
},
timeout=DELAI_S,
)
if reponse.status_code >= 300:
logger.warning(
"Journal agent : réponse %s de %s", reponse.status_code, url
)
return False
return True
except Exception as exc:
logger.warning("Journal agent injoignable (%s) : %s", url, exc)
return False
def emettre(
type_: str,
ref: str,
label: str,
url: str,
domain: str = "pro",
actor: str = "user",
) -> threading.Thread | None:
"""Émettre un événement, au mieux, depuis un fil d'arrière-plan.
Retourne le fil lancé (les tests peuvent le joindre), ou None
quand l'émission n'est pas configurée. Ne lève jamais.
"""
settings = get_settings()
cle = settings.agent_evenements_cle
if not cle:
return None
enveloppe = construire_enveloppe(
type_, ref, label, url, domain=domain, actor=actor
)
corps = json.dumps(enveloppe, ensure_ascii=False).encode("utf-8")
fil = threading.Thread(
target=_poster,
args=(settings.agent_evenements_url, corps, signer(corps, cle)),
name="kanbante-agent-journal",
daemon=True,
)
fil.start()
return fil
+87 -2
View File
@@ -96,6 +96,15 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
AGENT_EVENEMENTS_URL Journal endpoint of the agent
service (Lot 2, contract ACC-0.2)
receiving this app's signed events.
Default https://agent.dracodev.net/api/v1/evenements.
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
events it emits to that journal (the
service knows it as the "kanbante"
entry of AGENT_EVENEMENTS_CLES).
Empty (default) = emission disabled.
"""
import os
@@ -104,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.23.2"
APP_VERSION = "0.29.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -179,6 +188,9 @@ class Settings:
draco_secret_prev: str = ""
sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net"
archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = ""
@property
def auth_enabled(self) -> bool:
@@ -186,7 +198,13 @@ class Settings:
def get_settings() -> Settings:
"""Build settings from the current environment."""
"""Effective settings: environment, overlaid by the admin
panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS)."""
return apply_db_overrides(_env_settings())
def _env_settings() -> Settings:
"""Build settings from the current environment only."""
return Settings(
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
gitea_token=os.getenv("GITEA_TOKEN", ""),
@@ -230,4 +248,71 @@ def get_settings() -> Settings:
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
agent_evenements_url=os.getenv(
"AGENT_EVENEMENTS_URL",
"https://agent.dracodev.net/api/v1/evenements",
).rstrip("/"),
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
)
# ── Surcharge par le panneau d'administration (0.24.0, P3) ──
# L'environnement amorce, le panneau gouverne : les valeurs
# ci-dessous peuvent être remplacées en base (kv_store, clé
# "settings_overrides", JSON) depuis /admin/settings. La
# provenance reste affichée dans le panneau. Les secrets et les
# réglages d'amorçage (DB, clé maître, session, draco) ne sont
# volontairement PAS surchargeables.
OVERRIDABLE_SETTINGS: dict[str, type] = {
"active_window_days": int,
"cache_ttl": float,
"snapshot_interval": int,
"aging_days": int,
"zombie_days": int,
"archive_suggest_days": int,
"max_note_chars": int,
"briefing_time": str,
"briefing_include_standup": bool,
"digest_time": str,
"digest_weekday": int,
"capture_repo": str,
"sync_labels": bool,
"sync_gitea": bool,
}
def settings_overrides() -> dict:
"""The raw overrides stored by the admin panel ({} if none)."""
try:
import json as _json
from app import models
raw = models.get_kv("settings_overrides")
data = _json.loads(raw) if raw else {}
return data if isinstance(data, dict) else {}
except Exception:
return {}
def apply_db_overrides(settings: Settings) -> Settings:
"""Overlay the admin-panel values on the env-built settings."""
overrides = settings_overrides()
if not overrides:
return settings
import dataclasses
valid: dict = {}
for key, value in overrides.items():
converter = OVERRIDABLE_SETTINGS.get(key)
if converter is None or value is None:
continue
try:
if converter is bool:
valid[key] = str(value).lower() in ("1", "true", "on", "oui")
else:
valid[key] = converter(value)
except (TypeError, ValueError):
continue
return dataclasses.replace(settings, **valid) if valid else settings
+161
View File
@@ -0,0 +1,161 @@
"""Forge connection registry (0.24.0, P0/P1).
A *connection* is one configured forge access (Gitea or GitHub):
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
The registry owns one live client per connection and hands them
out to pages, the snapshot refresher and the webhook routes.
Bootstrap rules (zero-reconfiguration upgrades):
- the environment configuration (GITEA_URL / GITEA_TOKEN /
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
labelled "Gitea (hérité)", the first time the table is empty;
- an "Principal" workspace containing that connection is
created the same way;
- existing snapshot rows (unprefixed keys, written when only
one client existed) are re-keyed under the "c1:" prefix once.
"""
import logging
from app import forge_crypto, models
from app.config import get_settings
from app.gitea_client import GiteaClient
logger = logging.getLogger("kanbante.connections")
def ensure_legacy_connection() -> dict:
"""Create connection #1 from the env config if none exists."""
existing = models.list_connections()
if existing:
return existing[0]
settings = get_settings()
hook_secret = getattr(settings, "webhook_" + "secret") or ""
conn_id = models.create_connection(
"gitea",
"gitea",
"Gitea (hérité)",
settings.gitea_url,
forge_crypto.encrypt_token(settings.gitea_token)
if settings.gitea_token
else "",
"",
hook_secret,
)
logger.info("created legacy forge connection #%d (gitea)", conn_id)
return models.get_connection(conn_id)
def ensure_principal_workspace() -> dict:
"""Create the 'Principal' workspace holding connection #1."""
workspaces = models.list_workspaces()
if workspaces:
return workspaces[0]
legacy = ensure_legacy_connection()
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
models.set_workspace_connections(ws_id, [legacy["id"]])
logger.info("created Principal workspace #%d", ws_id)
return models.get_workspace(ws_id)
def prefix_legacy_snapshots(connection_id: int) -> None:
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
flag = f"snapshots_prefixed_c{connection_id}"
if models.get_kv(flag):
return
prefix = f"c{connection_id}:"
with models.get_db() as conn:
conn.execute(
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
(prefix, "c%:%"),
)
models.set_kv(flag, "1")
logger.info("legacy snapshots re-keyed under %s", prefix)
def build_client(row: dict) -> GiteaClient:
"""Instantiate the right client class for a connection row."""
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
if row["kind"] == "github":
from app.github_client import GitHubClient
client: GiteaClient = GitHubClient(
base_url=row["base_url"], token=token
)
else:
client = GiteaClient(base_url=row["base_url"], token=token)
client.connection_id = row["id"]
client.connection_slug = row["slug"]
client.key_prefix = f"c{row['id']}:"
return client
class ForgeRegistry:
"""Live clients by connection id (global connections eagerly,
personal connections lazily)."""
def __init__(self) -> None:
self._clients: dict[int, GiteaClient] = {}
self.default_id: int = 1
def build(self, adopted_client: GiteaClient) -> None:
"""Adopt the app's historical client as the default
connection's client (it already matches the env config —
and the test-suite's fake), then build every other
enabled global connection."""
legacy = ensure_legacy_connection()
self.default_id = legacy["id"]
adopted_client.connection_id = legacy["id"]
adopted_client.connection_slug = legacy["slug"]
adopted_client.key_prefix = f"c{legacy['id']}:"
self._clients[legacy["id"]] = adopted_client
for row in models.list_connections(include_disabled=False):
if row["id"] == legacy["id"] or row.get("owner_user_id"):
continue
try:
self._clients[row["id"]] = build_client(row)
except Exception as exc:
logger.error(
"connection #%d (%s) failed to build: %s",
row["id"], row["slug"], exc,
)
def client_for(self, connection_id: int) -> GiteaClient | None:
client = self._clients.get(connection_id)
if client is not None:
return client
row = models.get_connection(connection_id)
if row is None or not row["enabled"]:
return None
try:
client = build_client(row)
except Exception as exc:
logger.error("connection #%d build failed: %s", connection_id, exc)
return None
self._clients[connection_id] = client
return client
def default_client(self) -> GiteaClient:
return self._clients[self.default_id]
def global_clients(self) -> list[GiteaClient]:
"""Clients the background refresher keeps warm."""
out = []
for row in models.list_connections(include_disabled=False):
if row.get("owner_user_id"):
continue
client = self.client_for(row["id"])
if client is not None:
out.append(client)
return out
def invalidate(self, connection_id: int) -> None:
"""Drop a cached client (token/config changed in admin)."""
client = self._clients.pop(connection_id, None)
if client is not None and connection_id != self.default_id:
import asyncio
try:
asyncio.get_event_loop().create_task(client.aclose())
except RuntimeError:
pass
+1 -1
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.22.1"},
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
},
req_id=1,
)
+70
View File
@@ -0,0 +1,70 @@
"""Encryption at rest for forge tokens (0.24.0, P0/P2).
Forge access tokens are the keys to the kingdom: they are
stored Fernet-encrypted (AES-128-CBC + HMAC) in
forge_connections.token_encrypted and never shown again after
entry (the admin UI only ever displays a mask).
Master key resolution (decision D5):
1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended;
2. otherwise a random key generated ONCE and kept in kv_store
("master_key_generated"): as safe as the database file
itself, zero configuration, but it travels with DB backups —
set the env var for anything stricter.
"""
import logging
import os
import secrets as _secrets
from cryptography.fernet import Fernet
from app import models
logger = logging.getLogger("kanbante.crypto")
_fernet: Fernet | None = None
def _master_key() -> bytes:
env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip()
if env_key:
return env_key.encode("utf-8")
stored = models.get_kv("master_key_generated")
if stored:
return stored.encode("utf-8")
generated = Fernet.generate_key()
models.set_kv("master_key_generated", generated.decode("utf-8"))
logger.warning(
"KANBANTE_MASTER_KEY not set: generated a master key and "
"stored it in the database (kv_store). Set the env var to "
"control it explicitly."
)
return generated
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = Fernet(_master_key())
return _fernet
def encrypt_token(plain: str) -> str:
if not plain:
return ""
return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
def decrypt_token(stored: str) -> str:
if not stored:
return ""
try:
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
except Exception as exc:
logger.error("forge token decryption failed: %s", exc)
return ""
def mask_token(stored: str) -> str:
return "••••••••" if stored else "—"
+48 -8
View File
@@ -38,7 +38,18 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
class GiteaClient:
"""Lightweight async Gitea API client for Kanbante."""
"""Lightweight async Gitea API client for Kanbante.
Since 0.24.0 this class is the reference implementation of
the forge protocol: GitHubClient subclasses it and overrides
the endpoints whose shape differs. ``api_suffix`` and
``auth_scheme`` are the two seams; ``key_prefix`` namespaces
the persisted snapshots per connection ("c<id>:").
"""
kind = "gitea"
api_suffix = "/api/v1"
auth_scheme = "token"
def __init__(
self,
@@ -51,11 +62,15 @@ class GiteaClient:
self.base_url = (base_url or settings.gitea_url).rstrip("/")
self.token = settings.gitea_token if token is None else token
self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl
self.key_prefix = ""
self.connection_id = 1
self.connection_slug = "gitea"
headers = {"Accept": "application/json"}
headers.update(self._extra_headers())
if self.token:
headers["Authorization"] = f"token {self.token}"
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1",
base_url=f"{self.base_url}{self.api_suffix}",
headers=headers,
timeout=15.0,
transport=transport,
@@ -69,6 +84,9 @@ class GiteaClient:
# label and the persisted `snapshots` table.
self._snapshot_times: dict[str, str] = {}
def _extra_headers(self) -> dict:
return {}
async def aclose(self) -> None:
await self.client.aclose()
@@ -120,6 +138,9 @@ class GiteaClient:
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
def _prefixed(self, key: str) -> str:
return f"{self.key_prefix}{key}"
def _persist_snapshot(self, key: str, value: Any) -> None:
"""Write-through to the SQLite snapshots table.
@@ -129,7 +150,7 @@ class GiteaClient:
try:
from app import models
models.save_snapshot(key, value)
models.save_snapshot(self._prefixed(key), value)
import datetime
self._snapshot_times[key] = datetime.datetime.now(
@@ -157,12 +178,20 @@ class GiteaClient:
logger.warning("snapshot hydration failed: %s", exc)
return 0
stale_at = time.monotonic() - self.cache_ttl - 1.0
for key, (payload, updated_at) in snapshots.items():
count = 0
for stored_key, (payload, updated_at) in snapshots.items():
if self.key_prefix:
if not stored_key.startswith(self.key_prefix):
continue
key = stored_key[len(self.key_prefix):]
else:
key = stored_key
self._cache[key] = (stale_at, payload)
self._snapshot_times[key] = updated_at
if snapshots:
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
return len(snapshots)
count += 1
if count:
logger.info("hydrated %d snapshot(s) from SQLite", count)
return count
def latest_snapshot_at(self) -> str | None:
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
@@ -487,6 +516,17 @@ class GiteaClient:
self._cache_clear()
return r.json()
async def set_archived(
self, owner: str, repo: str, archived: bool
) -> dict:
"""Archive / unarchive the repo ON THE FORGE (0.24.0, P4)."""
r = await self.client.put(
f"/repos/{owner}/{repo}", json={"archived": archived}
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
+151
View File
@@ -0,0 +1,151 @@
"""GitHub forge client (0.24.0, P1).
Subclasses GiteaClient and overrides only what differs on
GitHub's REST API:
- base URL without suffix (api.github.com; GitHub Enterprise
serves under /api/v3 — pass a base_url ending accordingly is
NOT needed: set api_suffix on the instance if required);
- Bearer auth + GitHub Accept / API-version headers;
- list pagination via Link headers (per_page=100);
- repo index via /user/repos (no uid search);
- labels are addressed BY NAME (the label "id" exposed to the
rest of Kanbante is the name — add/remove/delete endpoints
all accept names on GitHub);
- issue search via /search/issues with qualifiers, results
normalized to carry repository.full_name like Gitea's;
- archiving via PATCH (Gitea uses PUT).
"""
import re
from typing import Any
from app.gitea_client import GiteaClient
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
class GitHubClient(GiteaClient):
kind = "github"
api_suffix = ""
auth_scheme = "Bearer"
def _extra_headers(self) -> dict:
return {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
# ── Pagination (Link headers) ──────────────────────────
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
items: list[dict] = []
url: str | None = path
query: dict[str, Any] = {**params, "per_page": 100}
pages = 0
while url and pages < 50:
pages += 1
if url.startswith("http"):
r = await self.client.get(url)
else:
r = await self.client.get(url, params=query)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "items" in data:
batch = data["items"]
elif isinstance(data, list):
batch = data
else:
batch = []
items.extend(batch)
match = _LINK_NEXT.search(r.headers.get("Link", ""))
url = match.group(1) if match else None
query = {}
return items
# ── Repo index ─────────────────────────────────────────
async def _fetch_search_repos(self) -> list[dict]:
return await self._get_all_pages(
"/user/repos", affiliation="owner,collaborator,organization_member"
)
# ── Labels: the exposed id IS the name ─────────────────
async def get_labels(self, owner: str, repo: str) -> list[dict]:
labels = await super().get_labels(owner, repo)
for label in labels:
label["id"] = label["name"]
return labels
async def create_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
label = await super().create_label(owner, repo, name, color)
label["id"] = label["name"]
return label
async def add_issue_label(
self, owner: str, repo: str, number: int, label_id
) -> None:
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/labels",
json={"labels": [str(label_id)]},
)
r.raise_for_status()
async def remove_issue_label(
self, owner: str, repo: str, number: int, label_id
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
)
r.raise_for_status()
# ── Issue search ───────────────────────────────────────
async def search_issues(
self,
q: str = "",
state: str = "open",
assigned_by: str | None = None,
) -> list[dict]:
qualifiers = [q.strip(), "is:issue", f"state:{state}"]
if assigned_by:
qualifiers.append(f"assignee:{assigned_by}")
query = " ".join(part for part in qualifiers if part)
raw = await self._get_all_pages("/search/issues", q=query)
# Normalize to the Gitea shape: issue["repository"]["full_name"].
for item in raw:
url = item.get("repository_url", "")
full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else ""
item["repository"] = {"full_name": full_name}
return raw
# ── Archiving (PATCH on GitHub) ────────────────────────
async def set_archived(
self, owner: str, repo: str, archived: bool
) -> dict:
r = await self.client.patch(
f"/repos/{owner}/{repo}", json={"archived": archived}
)
r.raise_for_status()
self._cache_clear()
return r.json()
# ── Branches (git/refs on GitHub) ──────────────────────
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
ref = await self._get(
f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}"
)
sha = (ref.get("object") or {}).get("sha", "")
r = await self.client.post(
f"/repos/{owner}/{repo}/git/refs",
json={"ref": f"refs/heads/{new_branch}", "sha": sha},
)
r.raise_for_status()
return r.json()
+2331 -235
View File
File diff suppressed because it is too large Load Diff
+766 -57
View File
File diff suppressed because it is too large Load Diff
+15 -10
View File
@@ -67,22 +67,27 @@ async def refresh_snapshots(gitea, classify_repos) -> int:
return len(active)
async def snapshot_loop(gitea, classify_repos) -> None:
async def snapshot_loop(clients_provider, classify_repos) -> None:
"""Periodic refresher, started by the app lifespan.
Sleeps briefly first: hydration already serves the pages,
the loop only has to keep the snapshots warm.
the loop only has to keep the snapshots warm. Since 0.24.0
`clients_provider` is a zero-arg callable returning the live
forge clients (one per global connection); each is refreshed
in turn. The interval is re-read every cycle so the admin
settings panel can change it without a restart.
"""
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
await asyncio.sleep(10)
while True:
try:
await refresh_snapshots(gitea, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
for client in clients_provider():
try:
await refresh_snapshots(client, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
await asyncio.sleep(interval)
+10 -4
View File
@@ -1,11 +1,12 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.3
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
<link rel="stylesheet" href="/static/css/draco-barre.css">
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
<script>document.body.classList.add('has-draco-bar');</script>
<header class="draco-bar" data-current="kanbante">
<div class="draco-bar-zone draco-bar-gauche">
@@ -86,7 +87,11 @@
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
@@ -95,4 +100,5 @@
}
};
</script>
<script defer src="/static/js/draco-barre.js"></script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
+26 -1
View File
@@ -28,9 +28,20 @@
("outils", "Outils", [
("search", "/search", "🔍", "Recherche"),
("capture", "/capture", "⚡", "Capture"),
("archives", "/archives", "🗄️", "Archives"),
("wall", "/wall", "🖥️", "Wallboard"),
]),
] %}
{% if is_admin %}
{% set nav_groups = nav_groups + [
("admin", "Administration", [
("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]),
] %}
{% endif %}
{% set active_group = namespace(key="principal") %}
{% for gkey, group, links in nav_groups %}
{% for key, href, icon, label in links %}
@@ -49,7 +60,7 @@
localStorage.removeItem('kanbante-nav-closed');
var openGroup = {{ active_group.key | tojson }};
var stored = localStorage.getItem('kanbante-nav-open');
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin'];
if ({{ (active_nav != "") | tojson }}) {
localStorage.setItem('kanbante-nav-open', openGroup);
} else if (stored === '') {
@@ -71,6 +82,17 @@
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
</div>
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
<form method="post" action="/workspace/switch" class="ws-switch">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<span class="ws-dot" style="background: {{ current_workspace.color }}" aria-hidden="true"></span>
<select name="workspace_id" class="ws-select" onchange="this.form.submit()" title="Changer d'espace de travail" aria-label="Espace de travail">
{% for ws in user_workspaces %}
<option value="{{ ws.id }}"{% if ws.id == current_workspace.id %} selected{% endif %}>{{ ws.name }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<nav class="side-nav">
{% for gkey, group, links in nav_groups %}
<div class="side-section" data-group="{{ gkey }}">
@@ -95,6 +117,9 @@
{% if data_freshness %}
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
{% endif %}
{% if current_user %}
<a href="/profile" class="side-logout" title="Mon profil">👤<span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
{% endif %}
<span class="side-version side-label">v{{ version }}</span>
{% if auth_enabled %}
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🛠️ Administration</h1>
<p class="page-sub">Utilisateurs, vue d'ensemble et journal d'administration.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>👥 Utilisateurs ({{ users|length }})</h2>
<table class="proj-table">
<thead>
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong>{% if u.display_name %} <span class="muted">({{ u.display_name }})</span>{% endif %}{% if u.must_change_password %} <span class="chip-warn">mot de passe à changer</span>{% endif %}</td>
<td>
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="role" onchange="this.form.submit()" {% if current_user.id == u.id %}disabled{% endif %}>
<option value="member"{% if u.role == 'member' %} selected{% endif %}>Membre</option>
<option value="admin"{% if u.role == 'admin' %} selected{% endif %}>Admin</option>
</select>
</form>
</td>
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</td>
<td>{{ u.last_login_at or '—' }}</td>
<td class="admin-actions">
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini" {% if current_user.id == u.id %}disabled title="Impossible sur soi-même"{% endif %}>{% if u.active %}Désactiver{% else %}Activer{% endif %}</button>
</form>
<form method="post" action="/admin/users/{{ u.id }}/reset" class="inline-form" onsubmit="var p=this.querySelector('input[name=password]'); if(!p.value){p.focus(); return false;}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="password" placeholder="Nouveau mot de passe" autocomplete="new-password">
<button type="submit" class="btn-mini">Réinitialiser</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>➕ Nouvel utilisateur</h3>
<form method="post" action="/admin/users" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom d'utilisateur <input type="text" name="username" required autocomplete="off"></label>
<label>Nom d'affichage <input type="text" name="display_name" autocomplete="off"></label>
<label>Mot de passe temporaire <input type="password" name="password" required autocomplete="new-password"></label>
<label>Rôle
<select name="role">
<option value="member">Membre</option>
<option value="admin">Admin</option>
</select>
</label>
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
<p class="notes-hint">L'utilisateur rejoint automatiquement l'espace Principal comme membre et devra changer son mot de passe temporaire (rappel affiché ici et dans son profil).</p>
</section>
<section class="briefing-card">
<h2>🔭 Vue d'ensemble</h2>
<p>
<a href="/admin/connections">🔌 Connexions ({{ connections|length }})</a> ·
<a href="/admin/workspaces">🧭 Espaces ({{ workspaces|length }})</a> ·
<a href="/admin/settings">🎛️ Paramètres</a> ·
<a href="/archives">🗄️ Archives</a>
</p>
</section>
<section class="briefing-card">
<h2>📜 Journal d'administration</h2>
{% if audit %}
<table class="proj-table">
<thead><tr><th>Quand</th><th>Qui</th><th>Action</th><th>Détails</th></tr></thead>
<tbody>
{% for a in audit %}
<tr><td>{{ a.created_at }}</td><td>{{ a.username or '—' }}</td><td><code>{{ a.action }}</code></td><td>{{ a.details }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucune action d'administration journalisée pour l'instant.</p>
{% endif %}
</section>
</main>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Connexions — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🔌 Connexions aux forges</h1>
<p class="page-sub">Chaque connexion est un accès Gitea ou GitHub. Les jetons sont <strong>chiffrés au repos</strong> et ne sont <strong>jamais réaffichés</strong> — pour en changer un, saisis la nouvelle valeur.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% for c in connections %}
<section class="briefing-card">
<h2>{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }}
<span class="muted">({{ c.slug }})</span>
{% if c.owner_user_id %}<span class="chip-warn">personnelle</span>{% endif %}
{% if not c.enabled %}<span class="chip-warn">désactivée</span>{% endif %}
</h2>
<p class="muted">
{{ c.kind }} · {{ c.base_url }} · compte : <strong>{{ c.account_login or 'à tester' }}</strong> · jeton : {{ c.token_mask }}<br>
Webhook : <code>/webhooks/{{ c.slug }}</code> (secret {% if c.webhook_secret %}défini{% else %}hérité ou absent{% endif %})
{% if c.used_by %} · Utilisée par : {{ c.used_by|join(', ') }}{% endif %}
</p>
<form method="post" action="/admin/connections/{{ c.id }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<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>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>
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/connections/{{ c.id }}/test" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">🧪 Tester la connexion</button>
</form>
<form method="post" action="/admin/connections/{{ c.id }}/toggle" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">{% if c.enabled %}Désactiver{% else %}Activer{% endif %}</button>
</form>
<form method="post" action="/admin/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer définitivement cette connexion ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
</form>
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle connexion</h2>
<form method="post" action="/admin/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
<select name="kind">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</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>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>
<button type="submit" class="btn-save btn-inline">Ajouter</button>
</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>
</section>
</main>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Paramètres — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🎛️ Paramètres</h1>
<p class="page-sub"><strong>L'environnement amorce, le panneau gouverne.</strong> Une valeur saisie ici remplace celle de l'environnement (sans redémarrage pour la plupart) ; un champ vidé revient à la valeur d'environnement. Les secrets et les réglages d'amorçage (base de données, clé maître, session, draco) restent en environnement, volontairement.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<form method="post" action="/admin/settings">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="proj-table">
<thead><tr><th>Réglage</th><th>Environnement</th><th>Valeur du panneau (vide = env)</th><th>Effectif</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td><code>{{ r.key }}</code></td>
<td class="muted">{{ r.env_value }}</td>
<td><input type="text" name="setting_{{ r.key }}" value="{{ r.override if r.override is not none else '' }}" placeholder="{{ r.env_value }}"></td>
<td><strong>{{ r.effective }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="submit" class="btn-save btn-inline">Enregistrer</button></p>
</form>
<p class="notes-hint">Booléens : <code>true</code>/<code>false</code>. <code>cache_ttl</code> s'applique aux connexions construites après l'enregistrement ; l'intervalle des instantanés est relu à chaque cycle. Les horaires (<code>briefing_time</code>, <code>digest_time</code>) sont au format <code>HH:MM</code>, <code>digest_weekday</code> : 0 = lundi.</p>
</section>
</main>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Espaces — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧭 Espaces de travail</h1>
<p class="page-sub">Un espace regroupe des connexions (forges) et des membres. Le board d'un projet appartient à l'espace : deux espaces peuvent ranger le même projet différemment. L'état de board, les notes et les épingles du jour sont propres à chaque espace.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% for d in details %}
{% set ws = d.ws %}
<section class="briefing-card">
<h2><span class="ws-dot" style="background: {{ ws.color }}"></span> {{ ws.name }} <span class="muted">({{ ws.slug }})</span></h2>
<form method="post" action="/admin/workspaces/{{ ws.id }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" value="{{ ws.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ ws.color }}"></label>
<button type="submit" class="btn-save btn-inline">Renommer</button>
</form>
<h3>🔌 Connexions de l'espace</h3>
<form method="post" action="/admin/workspaces/{{ ws.id }}/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for c in all_connections %}
<label>
<input type="checkbox" name="connection_id" value="{{ c.id }}"{% if c.id in d.conn_ids %} checked{% endif %}>
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
</label>
{% endfor %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<h3>👥 Membres</h3>
{% if d.members %}
<table class="proj-table">
<thead><tr><th>Utilisateur</th><th>Rôle dans l'espace</th><th></th></tr></thead>
<tbody>
{% for m in d.members %}
<tr>
<td>{{ m.username }}{% if m.display_name %} <span class="muted">({{ m.display_name }})</span>{% endif %}</td>
<td>{{ m.role }}</td>
<td>
<form method="post" action="/admin/workspaces/{{ ws.id }}/members/{{ m.user_id }}/remove" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Retirer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucun membre explicite (les admins globaux voient tout).</p>
{% endif %}
<form method="post" action="/admin/workspaces/{{ ws.id }}/members" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Utilisateur
<select name="user_id">
{% for u in all_users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
</label>
<label>Rôle
<select name="role">
<option value="reader">Lecteur</option>
<option value="member" selected>Membre</option>
<option value="admin">Admin d'espace</option>
</select>
</label>
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
</form>
<h3>📦 Sélection de projets</h3>
<p class="notes-hint">Vide = <strong>tous</strong> les projets des connexions de l'espace. Sinon, une ligne par projet au format <code>slug-connexion:proprietaire/depot</code> (ex. <code>github:bruno/mon-projet</code>).</p>
<form method="post" action="/admin/workspaces/{{ ws.id }}/projects">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="selection" rows="4" style="width:100%">{% for cid, full in d.selection %}{% for c in all_connections %}{% if c.id == cid %}{{ c.slug }}:{{ full }}
{% endif %}{% endfor %}{% endfor %}</textarea>
<p><button type="submit" class="btn-save btn-inline">Enregistrer la sélection</button></p>
</form>
{% if is_global_admin %}
<form method="post" action="/admin/workspaces/{{ ws.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cet espace ? Les boards propres à cet espace seront perdus.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer l'espace</button>
</form>
{% endif %}
</section>
{% endfor %}
{% if is_global_admin %}
<section class="briefing-card">
<h2>➕ Nouvel espace</h2>
<form method="post" action="/admin/workspaces" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" required placeholder="Open source"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
</section>
{% endif %}
</main>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Archives — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🗄️ Archives</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name if workspace else '—' }}</strong>. Deux sortes d'archives : <strong>locale</strong> (le projet disparaît des vues de cet espace, la forge n'est pas touchée, réversible en un clic) et <strong>sur la forge</strong> (propriété du dépôt, lecture seule là-bas). Les archives sortent des rituels et des instantanés, mais restent cherchables.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% macro actions(row, kind) %}
{% if can_write %}
{% if kind == 'suggested' or kind == 'forge' or kind == 'other' %}
<form method="post" action="/archives/local" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="1">
<button type="submit" class="btn-mini">📥 Archiver ici</button>
</form>
{% endif %}
{% if kind == 'local' %}
<form method="post" action="/archives/local" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="0">
<button type="submit" class="btn-mini">♻️ Restaurer</button>
</form>
<form method="post" action="/archives/purge" class="inline-form" onsubmit="return confirm('Purger l\'état local (board, notes, épingles) de ce projet dans cet espace ? La forge n\'est pas touchée.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<button type="submit" class="btn-mini btn-danger">Purger l'état local</button>
</form>
{% endif %}
{% if kind != 'forge' and not row.missing %}
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Archiver ce projet SUR LA FORGE ? Il y devient en lecture seule.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="1">
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
</form>
{% endif %}
{% if kind == 'forge' %}
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Désarchiver ce projet sur la forge ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="0">
<button type="submit" class="btn-mini">🔓 Désarchiver sur la forge</button>
</form>
{% endif %}
<form method="post" action="/archives/export" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
</form>
{% endif %}
{% endmacro %}
{% macro rowline(row, kind) %}
<tr>
<td>
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
<strong>{{ row.full_name }}</strong>
{% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}
{% if row.missing %}<span class="chip-warn">introuvable sur la forge</span>{% endif %}
</td>
<td>{{ row.updated_at or '—' }}{% if row.idle_days is not none %} <span class="muted">(il y a {{ row.idle_days }} j)</span>{% endif %}</td>
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
<td class="admin-actions">{{ actions(row, kind) }}</td>
</tr>
{% endmacro %}
<section class="briefing-card">
<h2>💤 À archiver ? (inactifs depuis {{ suggest_days }} jours et plus)</h2>
{% if groups.suggested %}
<form method="post" action="/archives/local-bulk">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="proj-table">
<thead><tr><th></th><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>
{% for row in groups.suggested %}
<tr>
<td><input type="checkbox" name="item" value="{{ row.conn_id }}:{{ row.full_name }}" checked></td>
<td>{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</td>
<td>{{ row.updated_at }} <span class="muted">(il y a {{ row.idle_days }} j)</span></td>
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
<td class="admin-actions">{{ actions(row, 'suggested') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="submit" class="btn-save btn-inline">📥 Archiver localement la sélection</button></p>
</form>
{% else %}
<p class="muted">Rien à proposer : aucun projet inactif au-delà du seuil (réglable : <code>archive_suggest_days</code> dans Administration → Paramètres).</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
{% if groups.local %}
<table class="proj-table">
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>{% for row in groups.local %}{{ rowline(row, 'local') }}{% endfor %}</tbody>
</table>
{% else %}
<p class="muted">Aucune archive locale. « Archiver ici » masque un projet de cet espace sans toucher à la forge.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
{% if groups.forge %}
<table class="proj-table">
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>{% for row in groups.forge %}{{ rowline(row, 'forge') }}{% endfor %}</tbody>
</table>
{% else %}
<p class="muted">Aucun dépôt archivé sur les forges de cet espace.</p>
{% endif %}
</section>
</main>
</body>
</html>
+2
View File
@@ -55,6 +55,7 @@
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
<a href="/archives" class="seg-link" title="Gérer les archives (locales et forge)">🗄️ Gérer</a>
</div>
<div class="search-bar toolbar-search">
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
@@ -89,6 +90,7 @@
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if multi_conn %}<span class="conn-badge" title="Connexion {{ repo._conn_slug }}">{% if repo._conn_kind == 'github' %}🐙{% else %}🍵{% endif %} {{ repo._conn_slug }}</span>{% endif %}
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Mon profil — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>👤 Mon profil</h1>
<p class="page-sub">{{ profile_user.username }} · rôle : {{ profile_user.role }}</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% if profile_user.must_change_password %}<p class="chip-warn">⚠️ Ton mot de passe est temporaire : change-le ci-dessous.</p>{% endif %}
<section class="briefing-card">
<h2>Informations</h2>
<form method="post" action="/profile" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom d'affichage <input type="text" name="display_name" value="{{ profile_user.display_name or '' }}"></label>
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="muted">Mes espaces :
{% for m in memberships %}<span class="ws-dot" style="background: {{ m.color }}"></span> {{ m.name }} ({{ m.member_role }}){% if not loop.last %} · {% endif %}
{% else %}aucun{% endfor %}
</p>
</section>
<section class="briefing-card">
<h2>🔑 Mot de passe</h2>
{% if profile_user.id %}
<form method="post" action="/profile/password" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Mot de passe actuel <input type="password" name="current_password" required autocomplete="current-password"></label>
<label>Nouveau mot de passe <input type="password" name="new_password" required autocomplete="new-password"></label>
<button type="submit" class="btn-save btn-inline">Changer</button>
</form>
{% else %}
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : le mot de passe se change dans la configuration du déploiement.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🗝️ Jetons d'API (MCP)</h2>
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
{% if profile_user.id %}
{% if nouveau_jeton %}
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
{% endif %}
{% if api_tokens %}
<ul>
{% for t in api_tokens %}
<li>
<strong>{{ t.name }}</strong> · créé le {{ t.created_at }}
· {% if t.last_used_at %}dernier usage : {{ t.last_used_at }}{% else %}jamais utilisé{% endif %}
{% if t.revoked_at %} · <span class="chip-warn">révoqué le {{ t.revoked_at }}</span>
{% else %}
<form method="post" action="/profile/tokens/{{ t.id }}/revoke" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Révoquer</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun jeton pour l'instant.</p>
{% endif %}
<form method="post" action="/profile/tokens" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom du jeton <input type="text" name="name" placeholder="service-agents" required></label>
<button type="submit" class="btn-save btn-inline">Créer un jeton</button>
</form>
{% else %}
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : les jetons appartiennent aux comptes de la base — connecte-toi avec ton compte personnel pour en créer un.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>✦ Liaison draco (SSO)</h2>
{% if profile_user.draco_sub %}
<p>Compte lié à l'identité draco <code>{{ profile_user.draco_sub }}</code> — la connexion via ObsiGate ouvre directement cette session.</p>
<form method="post" action="/profile/draco/unlink" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Délier</button>
</form>
{% else %}
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🔌 Mes connexions personnelles</h2>
<p class="muted">Visibles de toi seul. Pour les utiliser, ajoute-les à un de tes espaces (ou demande à un admin d'espace).</p>
{% for c in my_connections %}
<p>
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ c.name }}</strong>
<span class="muted">({{ c.slug }}) · {{ c.base_url }} · compte : {{ c.account_login or 'à tester' }} · jeton : {{ c.token_mask }}</span>
<form method="post" action="/profile/connections/{{ c.id }}/test" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">🧪 Tester</button>
</form>
<form method="post" action="/profile/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette connexion personnelle ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
</form>
</p>
{% endfor %}
<form method="post" action="/profile/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
<select name="kind">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Nom <input type="text" name="name" required></label>
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com"></label>
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
<button type="submit" class="btn-save btn-inline">Ajouter</button>
</form>
</section>
</main>
</body>
</html>
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.23.2"
version = "0.29.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28",
"python-dotenv>=1.0",
"python-multipart>=0.0.18",
"cryptography>=42",
]
[tool.setuptools.packages.find]
+8 -3
View File
@@ -1,7 +1,8 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21).
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).
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -143,14 +144,18 @@
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
restaient affichés et la barre débordait sur téléphone.) */
restaient affichés et la barre débordait sur téléphone.
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
barre complète ne tenait pas (fenêtres réduites, tablettes,
webviews) alors que les apps sont déjà en mise en page mobile.) */
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
.draco-apptab { display: none; }
+189
View File
@@ -0,0 +1,189 @@
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
prefers-color-scheme) et suit les bascules en direct. Toutes les
couleurs passent par les variables ci-dessous — en-tête et fenêtre
partagent donc toujours le même mode. */
.draco-panneau {
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
--p-accent: #7c5cd6; --p-lien: #5b3df0;
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
background: var(--p-fond); color: var(--p-texte); z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
}
.draco-panneau[data-draco-theme="dark"] {
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
--p-lien: #b39df5;
}
.draco-panneau.open { transform: translateX(0); }
.draco-poignee {
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 5; touch-action: none;
}
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
@media (max-width: 700px) { .draco-poignee { display: none; } }
.draco-panneau-hd {
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */
.draco-disc-bar {
display: flex; align-items: center; gap: 7px;
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-disc-bar .d-titre {
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-disc-bar .d-btn {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
}
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
.draco-discussions {
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
max-height: 46%; overflow-y: auto; padding: 8px 12px;
}
.draco-discussions .d-section {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
}
.draco-discussions .d-section:first-child { margin-top: 0; }
.draco-discussions .d-item {
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
}
.draco-discussions .d-item:hover { background: var(--p-chip); }
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
.draco-discussions .d-actions button {
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
}
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
.draco-discussions .d-vide, .draco-discussions .d-chargement {
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
}
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
.draco-panneau-outils button {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
.draco-appro {
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px;
}
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
.draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
}
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
.draco-msg.md code, .m-rapport.md code {
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.draco-msg.md pre, .m-rapport.md pre {
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
overflow-x: auto; margin: 7px 0;
}
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
.draco-msg.md blockquote, .m-rapport.md blockquote {
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
color: var(--p-doux);
}
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
background: var(--p-chip);
}
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
.draco-msg.md table, .m-rapport.md table {
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
display: block; overflow-x: auto; max-width: 100%;
}
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
}
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
+48 -1
View File
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; }
/* — Base des champs de saisie (#29) : sans règle de fond, tout
champ hors d'un contexte stylé (.login-form, .capture-form,
.admin-form…) gardait le fond BLANC du navigateur — vu sur la
réinitialisation de mot de passe (Administration), la sélection
de projets (Espaces, textarea jamais couverte) et les valeurs
du panneau (Paramètres). Sélecteur en :where() = spécificité
zéro : cette base ne peut écraser AUCUNE règle de contexte
existante, elle ne fait que combler les vides. */
:root { color-scheme: dark; }
:where(input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]),
textarea, select) {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
:where(input, textarea, select):focus-visible {
outline: 2px solid rgba(124, 92, 252, 0.55);
outline-offset: 0;
}
/* ── App header ─────────────────────────────── */
.app-header, header {
position: sticky;
@@ -1632,7 +1654,32 @@ a.ms-title:hover { text-decoration: underline; }
body.has-draco-bar { padding-top: 60px; }
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
body.has-draco-bar .sidebar { top: 60px; }
@media (max-width: 700px) {
@media (max-width: 900px) {
body.has-draco-bar { padding-top: 52px; }
body.has-draco-bar .sidebar { top: 52px; }
}
/* ── Espaces, admin, profils (0.24.0) ─────────────────── */
.ws-switch { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; }
.ws-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #1c1c22); color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 8px; font: inherit; }
body.sidebar-collapsed .ws-switch { display: none; }
.admin-form { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; margin: 10px 0; }
.admin-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85em; }
.admin-form input[type="text"], .admin-form input[type="password"], .admin-form select {
background: var(--surface-2, #1c1c22); color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 7px 9px; font: inherit; }
.inline-form { display: inline-flex; gap: 6px; align-items: center; }
.btn-mini { padding: 4px 10px; font-size: 0.85em; border-radius: 8px;
border: 1px solid var(--border, #333); background: var(--surface-2, #1c1c22);
color: inherit; cursor: pointer; }
.btn-mini:hover { border-color: var(--accent, #7c5cfc); }
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-danger:hover { border-color: #e5534b; color: #e5534b; }
.chip-ok { display: inline-block; padding: 4px 10px; border-radius: 999px;
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.5); }
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border, #333); white-space: nowrap; }
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
+816
View File
@@ -0,0 +1,816 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps ;
* la réponse arrive avec son HTML
* Markdown rendu côté service (v2)
* GET /api/v1/conversations — discussions façon Muse (v2) : une
* principale perpétuelle + des
* parallèles créées/archivées/
* supprimées par l'utilisateur
* GET /api/v1/conversations/… — historique d'une discussion (les
* messages agent portent leur html)
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5)
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (agent.dracodev.net) DANS la page, en surimpression
* pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle
* du premier chargement — l'iframe est créé paresseusement et
* conservé, la console garde son état entre ouvertures. La barre
* d'outils de la surimpression offre « Mode sidebar » (retour au
* panneau seul, animation inverse) et « Fermer l'agent » (referme
* surimpression et panneau).
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
var convId = null;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
}
function stockerConv() {
try {
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
else sessionStorage.removeItem("draco_panneau_conv");
} catch (e) { /* stockage indisponible */ }
}
function convStockee() {
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
catch (e) { return null; }
}
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function urlConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
return API + "/" + (params.length ? "?" + params.join("&") : "");
}
/* La surimpression vit entièrement dans ce fichier (DOM + styles
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
* thème suit celui du panneau (data-draco-theme synchronisé par
* appliquerTheme) et sa palette reprend les mêmes valeurs que
* draco-panneau.css. L'iframe est créé au premier clic puis
* conservé : rouvrir la console ne le recharge pas. */
var surEl = null, surCadre = null, surOuverte = false;
var STYLES_CONSOLE =
".draco-console-sur{" +
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
"background:var(--p-fond);color:var(--p-texte);" +
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
"display:flex;flex-direction:column;transition:width .3s ease;" +
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
".draco-console-sur[data-draco-theme=\"dark\"]{" +
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
".draco-console-sur[hidden]{display:none}" +
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
function assurerSurimpression() {
if (surEl) return;
if (!document.getElementById("draco-console-styles")) {
var st = document.createElement("style");
st.id = "draco-console-styles";
st.textContent = STYLES_CONSOLE;
document.head.appendChild(st);
}
surEl = document.createElement("div");
surEl.className = "draco-console-sur";
surEl.setAttribute("aria-label", "Console Spark");
surEl.hidden = true;
surEl.innerHTML =
'<div class="draco-console-barre">' +
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
' <span class="dc-actions">' +
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
' </span></div>' +
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
document.body.appendChild(surEl);
surCadre = surEl.querySelector("iframe");
surEl.querySelector('[data-dc="sidebar"]')
.addEventListener("click", modeSidebarConsole);
surEl.querySelector('[data-dc="fermer"]')
.addEventListener("click", fermerAgentConsole);
appliquerTheme();
}
function largeurPanneau() {
if (root) {
var w = root.getBoundingClientRect().width;
if (w > 0) return Math.round(w);
}
return 388;
}
function ouvrirConsole() {
assurerSurimpression();
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
if (surOuverte) return;
surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis
* extension à toute la page ; le double rAF laisse le temps de
* peindre la largeur de départ, sinon la transition ne joue pas. */
surEl.style.width = largeurPanneau() + "px";
surEl.hidden = false;
requestAnimationFrame(function () {
requestAnimationFrame(function () { surEl.style.width = "100%"; });
});
}
function retrecirConsole() {
if (!surEl || !surOuverte) return;
surOuverte = false;
var fini = false;
function terminer() {
if (fini) return;
fini = true;
surEl.removeEventListener("transitionend", surFin);
surEl.hidden = true; // l'iframe reste chargé, juste masqué
}
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
surEl.addEventListener("transitionend", surFin);
setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px";
}
function modeSidebarConsole() { retrecirConsole(); }
function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
if (t === "dark" || t === "light") return t;
if (document.documentElement.classList.contains("dark") ||
document.body.classList.contains("dark")) return "dark";
try {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
} catch (e) { /* pas de matchMedia */ }
return "light";
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
new MutationObserver(appliquerTheme).observe(document.documentElement,
{ attributes: true, attributeFilter: ["data-theme", "class"] });
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", appliquerTheme);
} catch (e) { /* surveillance facultative */ }
}
/* ── Largeur réglable ──────────────────────────────────────── */
function largeurStockee() {
try {
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
return isNaN(v) ? null : v;
} catch (e) { return null; }
}
function appliquerLargeur() {
if (!root) return;
var v = largeurStockee();
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
}
function brancherPoignee(poignee) {
poignee.addEventListener("pointerdown", function (ev) {
if (window.innerWidth <= 700) return; // mobile : pleine largeur
ev.preventDefault();
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
function bouger(e2) {
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
root.style.width = w + "px";
}
function lacher(e2) {
document.removeEventListener("pointermove", bouger);
document.removeEventListener("pointerup", lacher);
var w = Math.round(root.getBoundingClientRect().width);
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
}
document.addEventListener("pointermove", bouger);
document.addEventListener("pointerup", lacher);
});
poignee.addEventListener("dblclick", function () {
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
appliquerLargeur();
});
}
function construire() {
root = document.createElement("aside");
root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML =
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
'<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <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>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
/* Surimpression ouverte : Échap = retour au panneau seul
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
* console (autre origine), l'événement ne remonte pas
* jusqu'ici : il ne peut donc pas fermer par surprise. */
if (surOuverte) { modeSidebarConsole(); return; }
if (root.classList.contains("open")) fermer();
});
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
/* Message agent rendu en Markdown : le HTML vient du service
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
function ajouterMsgMd(texte, html) {
var d = document.createElement("div");
d.className = "draco-msg agent md";
if (html) d.innerHTML = html;
else d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
}
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
/* ── Discussions (façon Muse) ────────────────────────────── */
function chargerListeDiscussions() {
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
{ credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
}
function demarrerDiscussions() {
chargerListeDiscussions()
.then(function (d) {
var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
var active = trouvee || d.principale;
if (!active) return;
convId = active.id; convKind = active.kind; convTitre = active.title;
stockerConv(); majTitre();
chargerHistorique();
})
.catch(function () {
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
});
}
function basculerDiscussion(c) {
convId = c.id; convKind = c.kind; convTitre = c.title;
stockerConv(); majTitre();
tiroirEl.hidden = true;
msgsEl.innerHTML = "";
ajouterMsg("sys", (c.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
chargerHistorique();
inputEl.focus();
}
function nouvelleDiscussion() {
fetch(API + "/api/v1/conversations", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: domaine() })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (c) {
basculerDiscussion(c);
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
})
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
}
function actionDiscussion(id, action) {
var methode = action === "supprimer" ? "DELETE" : "POST";
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
return fetch(url, { method: methode, credentials: "include" })
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
});
}
function ligneDiscussion(c, actions) {
var ligne = document.createElement("div");
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
var meta = document.createElement("div");
meta.className = "d-meta";
var quand = "";
try {
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
{ day: "numeric", month: "short" });
} catch (e) { /* date illisible */ }
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
ligne.appendChild(meta);
var sous = document.createElement("div");
sous.className = "d-sous";
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
(c.kind === "principale" ? " · perpétuelle" : "");
ligne.appendChild(sous);
meta.addEventListener("click", function () { basculerDiscussion(c); });
sous.addEventListener("click", function () { basculerDiscussion(c); });
if (actions && actions.length) {
var btns = document.createElement("div");
btns.className = "d-actions";
actions.forEach(function (a) {
var b = document.createElement("button");
b.type = "button"; b.textContent = a.label;
if (a.danger) b.className = "danger";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
if (a.confirmer && !window.confirm(a.confirmer)) return;
actionDiscussion(c.id, a.action)
.then(function () {
if (c.id === convId && a.action !== "desarchiver") {
chargerListeDiscussions().then(function (d) {
if (d.principale) {
basculerDiscussion(d.principale);
ajouterMsg("sys", a.action === "supprimer"
? "Discussion supprimée — retour à la discussion principale."
: "Discussion archivée — retour à la discussion principale.");
}
});
}
remplirTiroir();
})
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
});
btns.appendChild(b);
});
ligne.appendChild(btns);
}
return ligne;
}
function remplirTiroir() {
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
chargerListeDiscussions()
.then(function (d) {
tiroirEl.innerHTML = "";
var h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussion principale";
tiroirEl.appendChild(h);
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussions parallèles";
tiroirEl.appendChild(h);
if (!d.paralleles.length) {
var vide = document.createElement("div");
vide.className = "d-vide";
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
tiroirEl.appendChild(vide);
}
d.paralleles.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Archiver", action: "archive" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
if (d.archivees && d.archivees.length) {
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Archivées";
tiroirEl.appendChild(h);
d.archivees.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Désarchiver", action: "desarchiver" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
}
})
.catch(function () {
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
});
}
function basculerTiroir() {
tiroirEl.hidden = !tiroirEl.hidden;
if (!tiroirEl.hidden) remplirTiroir();
}
function chargerHistorique() {
if (!convId) return;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) { convId = null; stockerConv(); return null; }
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d) return;
convKind = d.kind; convTitre = d.title || convTitre;
majTitre();
if (!msgsEl.querySelector(".draco-msg")) {
ajouterMsg("sys", (d.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
}
var sys = msgsEl.querySelector(".draco-msg.sys");
(d.messages || []).forEach(function (m) {
var el = m.role === "user"
? ajouterMsg("user", m.content)
: ajouterMsgMd(m.content, m.html);
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : on repart à neuf */ });
}
function chargerAujourdhui() {
var bloc = document.createElement("div");
bloc.className = "draco-auj";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
})
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var entete = document.createElement("div");
entete.className = "m-entete";
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
ligne.appendChild(entete);
var meta = document.createElement("div");
meta.className = "m-meta";
meta.textContent = m.executor +
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
ligne.appendChild(meta);
if (m.result) {
var rapport = document.createElement("div");
rapport.className = "m-rapport md";
if (m.result_html) rapport.innerHTML = m.result_html;
else rapport.textContent = String(m.result).slice(0, 1200);
ligne.appendChild(rapport);
}
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
convKind = d.kind || convKind;
if (d.title) convTitre = d.title;
stockerConv(); majTitre();
if (d.html) {
attente.className = "draco-msg agent md";
attente.innerHTML = d.html;
} else {
attente.textContent = d.reply;
}
msgsEl.scrollTop = msgsEl.scrollHeight;
if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open");
appliquerTheme();
verifierIdentite();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+14 -1
View File
@@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
class FakeGitea:
"""Drop-in async replacement for GiteaClient used by the API tests."""
kind = "gitea"
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.connection_id = 1
self.connection_slug = "gitea"
self.key_prefix = ""
self.starred = set()
self.repos = [
_repo("demo", now - timedelta(days=1)),
@@ -136,9 +141,17 @@ class FakeGitea:
results.append({**issue, "repository": {"full_name": full_name}})
return results
async def set_archived(self, owner, repo, archived):
for r in self.repos:
if r["full_name"] == f"{owner}/{repo}":
r["archived"] = archived
return {"archived": archived}
async def delete_label(self, owner, repo, label_id):
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
for name in [n for n, l in labels.items() if l["id"] == label_id]:
for name in [
n for n, l in labels.items() if str(l["id"]) == str(label_id)
]:
del labels[name]
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
+186
View File
@@ -0,0 +1,186 @@
"""Lot 2, vague 2 — émission d'événements de Kanbante vers le
journal du service d'agents (contrat ACC-0.2).
Couvre : l'enveloppe signée (HMAC recalculé côté test), l'inactivité
sans clé, l'émission sur déplacement de carte (``kanbante.card.moved``)
et sur entrée en Révision (``kanbante.card.review_requested``), et
l'absence totale d'effet d'une panne du récepteur sur le métier.
"""
import hashlib
import hmac
import json
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from app import agent_journal, models
from tests.conftest import csrf_from
CLE = "cle-kanbante-de-test"
class Recepteur:
"""Faux service d'agents : capture corps bruts et signatures."""
def __init__(self) -> None:
self.recus: list[tuple[bytes, str]] = []
self._verrou = threading.Lock()
recus = self.recus
verrou = self._verrou
class Mains(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
corps = self.rfile.read(int(self.headers.get("Content-Length", 0)))
with verrou:
recus.append((corps, self.headers.get("X-Draco-Signature", "")))
self.send_response(201)
self.end_headers()
self._serveur = ThreadingHTTPServer(("127.0.0.1", 0), Mains)
threading.Thread(target=self._serveur.serve_forever, daemon=True).start()
self.url = f"http://127.0.0.1:{self._serveur.server_address[1]}/api/v1/evenements"
def enveloppes(self, type_: str | None = None, ref: str | None = None) -> list[dict]:
with self._verrou:
bruts = list(self.recus)
envs = [json.loads(corps) for corps, _ in bruts]
if type_ is not None:
envs = [e for e in envs if e["type"] == type_]
if ref is not None:
envs = [e for e in envs if e["object"]["ref"] == ref]
return envs
def attendre(self, type_: str, ref: str, timeout: float = 5.0) -> dict:
"""Poll jusqu'à l'arrivée de l'événement voulu (envoi en
arrière-plan : l'attente fait partie du contrat de test)."""
limite = time.monotonic() + timeout
while time.monotonic() < limite:
trouves = self.enveloppes(type_, ref)
if trouves:
return trouves[0]
time.sleep(0.05)
raise AssertionError(f"Événement {type_} pour {ref} jamais reçu")
@pytest.fixture
def recepteur():
return Recepteur()
@pytest.fixture
def journal_actif(monkeypatch, recepteur):
"""Émission configurée vers le faux récepteur local."""
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
return recepteur
def test_enveloppe_signee_verifiable(journal_actif):
fil = agent_journal.emettre(
"kanbante.card.moved",
"issue:Projets/demo#1",
"Tâche un",
"http://gitea.test/Projets/demo/issues/1",
actor="user",
)
assert fil is not None
fil.join(timeout=5)
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
corps, signature = journal_actif.recus[0]
# La signature se revérifie indépendamment, comme le fait le service.
assert signature == hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert json.loads(corps) == env
assert env["id"]
assert env["source_app"] == "kanbante"
assert env["domain"] == "pro"
assert env["actor"] == "user"
assert env["object"] == {
"ref": "issue:Projets/demo#1",
"label": "Tâche un",
"url": "http://gitea.test/Projets/demo/issues/1",
}
# occurred_at : ISO 8601 lisible par le service.
datetime.fromisoformat(env["occurred_at"])
def test_inactif_sans_cle(monkeypatch, recepteur):
monkeypatch.delenv("AGENT_EVENEMENTS_CLE", raising=False)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
assert agent_journal.actif() is False
assert agent_journal.emettre(
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
"http://gitea.test/Projets/demo/issues/1",
) is None
time.sleep(0.3)
assert recepteur.recus == []
def _deplacer(client, numero: int, de: str, vers: str):
token = csrf_from(client)
r = client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo",
"issue_number": numero,
"to_column": vers,
"from_column": de,
"csrf_token": token,
},
)
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
return r
def test_deplacement_emet_moved(client, journal_actif):
_deplacer(client, 1, "backlog", "in_progress")
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
assert env["actor"] == "user"
assert env["object"]["label"] == "Tâche un"
assert env["object"]["url"] == "http://gitea.test/Projets/demo/issues/1"
# Un déplacement hors Révision n'émet pas de review_requested.
time.sleep(0.3)
assert journal_actif.enveloppes(
"kanbante.card.review_requested", "issue:Projets/demo#1"
) == []
def test_entree_en_revision_emet_review_requested(client, journal_actif):
_deplacer(client, 1, "backlog", "review")
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
env = journal_actif.attendre(
"kanbante.card.review_requested", "issue:Projets/demo#1"
)
assert env["object"]["label"] == "Tâche un"
assert env["actor"] == "user"
# Sortir de Révision ne redemande pas de révision.
_deplacer(client, 1, "review", "done")
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
time.sleep(0.3)
assert len(journal_actif.enveloppes(
"kanbante.card.review_requested", "issue:Projets/demo#1"
)) == 1
def test_panne_du_recepteur_sans_effet_sur_le_deplacement(client, monkeypatch):
# Récepteur mort (port 1 fermé) mais émission configurée.
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:1/api/v1/evenements")
_deplacer(client, 1, "backlog", "in_progress")
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["in_progress"]] == [1]
# L'échec d'envoi, lui, reste silencieux et borné dans le temps.
fil = agent_journal.emettre(
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
"http://gitea.test/Projets/demo/issues/1",
)
assert fil is not None
fil.join(timeout=10)
assert not fil.is_alive()
+71
View File
@@ -0,0 +1,71 @@
"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer.
Le service d'agents se fait refuser par Kanbante depuis la 0.24.0
(auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent
une identité machine propre, révocable, limitée à /mcp et /api/.
"""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
@pytest.fixture()
def utilisateur():
# La base de test est partagée par toute la session : nom unique,
# et nettoyage complet au teardown — le pont SSO (test_draco_sso)
# a une sémantique « un seul compte en base » dont hérite la suite.
import uuid
uid = models.create_user(
f"machine-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_jeton_cycle_de_vie(utilisateur):
row, clair = models.create_api_token(utilisateur["id"], "service-agents")
assert clair.startswith("kbt_")
# La base ne connaît que l'empreinte, jamais le texte clair.
assert row["token_hash"] != clair and len(row["token_hash"]) == 64
trouve = models.user_by_api_token(clair)
assert trouve is not None and trouve["username"] == utilisateur["username"]
assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None
# Révocation : effet immédiat.
assert models.revoke_api_token(utilisateur["id"], row["id"]) is True
assert models.user_by_api_token(clair) is None
# On ne révoque pas le jeton d'autrui.
row2, _clair2 = models.create_api_token(utilisateur["id"], "autre")
assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False
def test_jeton_invalide_ou_compte_desactive(utilisateur):
_row, clair = models.create_api_token(utilisateur["id"], "x")
assert models.user_by_api_token("kbt_nimportequoi") is None
assert models.user_by_api_token("") is None
assert models.user_by_api_token("tck_autreformat") is None
models.update_user(utilisateur["id"], active=0)
assert models.user_by_api_token(clair) is None
def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05"}}
with TestClient(main.app) as client:
# Sans jeton : 401 (le refus vu en production le 2026-10-21).
assert client.post("/mcp", json=corps).status_code == 401
# Avec le jeton Bearer : le handshake passe.
r = client.post("/mcp", json=corps,
headers={"Authorization": f"Bearer {clair}"})
assert r.status_code == 200
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
# Mais un jeton n'ouvre pas l'interface web.
assert client.get("/", headers={"Authorization": f"Bearer {clair}",
"Accept": "application/json"}).status_code == 401
+3
View File
@@ -31,6 +31,9 @@ def test_menu_des_applications_par_la_marque(client):
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
accueil = client.get("/")
assert 'data-act="apps"' in accueil.text
# 0.23.3 : assets versionnés, sinon le cache garde l'ancien JS.
assert "draco-barre.css?v=" in accueil.text
assert "draco-barre.js?v=" in accueil.text
js = client.get("/static/js/draco-barre.js").text
assert "draco-apps-menu" in js
css = client.get("/static/css/draco-barre.css").text
+117
View File
@@ -0,0 +1,117 @@
"""GitHubClient (0.24.0, P1): same protocol as GiteaClient,
GitHub wire shape underneath. Driven through MockTransport."""
import httpx
import pytest
from app.github_client import GitHubClient
def make_client(handler) -> GitHubClient:
return GitHubClient(
base_url="https://api.github.com",
token="ghp_test",
cache_ttl=0,
transport=httpx.MockTransport(handler),
)
async def test_auth_header_is_bearer():
seen = {}
def handler(request):
seen["auth"] = request.headers.get("authorization")
seen["accept"] = request.headers.get("accept")
return httpx.Response(200, json={"login": "octocat"})
client = make_client(handler)
user = await client.get_user()
assert user["login"] == "octocat"
assert seen["auth"] == "Bearer ghp_test"
assert "application/vnd.github+json" in seen["accept"]
async def test_pagination_follows_link_headers():
def handler(request):
if "page=2" in str(request.url):
return httpx.Response(200, json=[{"full_name": "a/deux"}])
return httpx.Response(
200,
json=[{"full_name": "a/un"}],
headers={
"Link": '<https://api.github.com/user/repos?page=2>; rel="next"'
},
)
client = make_client(handler)
repos = await client._fetch_search_repos()
assert [r["full_name"] for r in repos] == ["a/un", "a/deux"]
async def test_labels_are_addressed_by_name():
seen = {}
def handler(request):
if request.method == "GET" and request.url.path.endswith("/labels"):
return httpx.Response(
200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}]
)
if request.method == "POST":
seen["body"] = request.content.decode()
return httpx.Response(200, json=[])
raise AssertionError(request.url)
client = make_client(handler)
label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6")
assert label["id"] == "kanban:todo" # name, not GitHub's numeric id
await client.add_issue_label("a", "b", 7, label["id"])
assert '"kanban:todo"' in seen["body"]
async def test_search_issues_normalizes_repository():
def handler(request):
assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url)
return httpx.Response(
200,
json={
"items": [
{
"number": 3,
"title": "T",
"repository_url": "https://api.github.com/repos/a/b",
}
]
},
)
client = make_client(handler)
issues = await client.search_issues(q="mot", state="open")
assert issues[0]["repository"]["full_name"] == "a/b"
async def test_set_archived_uses_patch():
seen = {}
def handler(request):
seen["method"] = request.method
seen["body"] = request.content.decode()
return httpx.Response(200, json={"archived": True})
client = make_client(handler)
await client.set_archived("a", "b", True)
assert seen["method"] == "PATCH"
assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"]
async def test_starred_roundtrip():
seen = {}
def handler(request):
seen["method"] = request.method
seen["path"] = request.url.path
return httpx.Response(204)
client = make_client(handler)
await client.set_starred("a", "b", True)
assert seen["method"] == "PUT"
assert seen["path"] == "/user/starred/a/b"
+202
View File
@@ -0,0 +1,202 @@
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
Le service d'agents doit pouvoir proposer des cartes et commenter
via Kanbante, sans passer par l'interface web. Les deux outils
réutilisent les services existants (création via _do_capture →
client de forge, commentaire via client.create_comment) et sont
gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
"""
import json
import uuid
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
def _call(client, name, arguments, headers=None):
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
}
return client.post("/mcp", json=payload, headers=headers or {})
def _result_text(response):
result = response.json()["result"]
return result, result["content"][0]["text"]
# ── Création de carte ──────────────────────────────────────
def test_mcp_create_issue_places_card(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Carte créée par MCP", "body": "Corps de la carte",
})
result, text = _result_text(r)
assert result["isError"] is False
created = json.loads(text)
assert created["title"] == "Carte créée par MCP"
assert created["repo_full_name"] == "Projets/demo"
assert created["column"] == "backlog"
assert created["number"] > 0
# L'écriture est passée par le client de forge existant.
assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")]
# La carte est placée sur le board (lecture MCP relue).
r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"})
board = json.loads(_result_text(r)[1])
assert any(
c["title"] == "Carte créée par MCP" for c in board["backlog"]
)
# Journal : création tracée avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
created_events = [
e for e in events
if e["event_type"] == "created"
and e["issue_number"] == created["number"]
]
assert created_events and created_events[0]["source"] == "mcp"
def test_mcp_create_issue_column_choice(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Directement à faire", "column": "todo",
})
result, text = _result_text(r)
assert result["isError"] is False
assert json.loads(text)["column"] == "todo"
def test_mcp_create_issue_validation_errors(client, fake_gitea):
# Titre manquant : « Missing argument » (patron d'erreur du serveur).
r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
# Titre vide et colonne inconnue : erreurs métier en isError.
for arguments in (
{"owner": "Projets", "repo": "demo", "title": " "},
{"owner": "Projets", "repo": "demo", "title": "X",
"column": "nimporte"},
):
result, _text = _result_text(_call(client, "create_issue", arguments))
assert result["isError"] is True
assert fake_gitea.created == []
# ── Commentaire ────────────────────────────────────────────
def test_mcp_add_comment_posts_on_forge(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Commentaire du service d'agents",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["number"] == 1
assert payload["repo_full_name"] == "Projets/demo"
assert payload["body"] == "Commentaire du service d'agents"
assert fake_gitea.posted_comments == [
("Projets", "demo", 1, "Commentaire du service d'agents")
]
events = models.get_events(repo_full_name="Projets/demo", limit=10)
commented = [e for e in events if e["event_type"] == "commented"]
assert commented and commented[0]["source"] == "mcp"
def test_mcp_add_comment_validation_errors(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": " ",
})
assert _result_text(r)[0]["isError"] is True
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "body": "sans numéro",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
assert fake_gitea.posted_comments == []
def test_mcp_write_tools_are_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["create_issue"]["inputSchema"]["required"] == [
"owner", "repo", "title",
]
assert tools["add_comment"]["inputSchema"]["required"] == [
"owner", "repo", "number", "body",
]
# Aucun outil destructeur n'apparaît.
assert not any(
name.startswith(("delete", "remove", "close"))
for name in tools
)
# ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture()
def utilisateur():
# Base de test partagée : nom unique, nettoyage complet au
# teardown (le pont SSO a une sémantique « un seul compte »).
uid = models.create_user(
f"mcp-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
jeton = {"Authorization": f"Bearer {clair}"}
create_args = {
"owner": "Projets", "repo": "demo", "title": "Carte par jeton",
}
with TestClient(main.app) as client:
# Sans jeton : refusé (401), aucune écriture sur la forge.
assert _call(client, "create_issue", create_args).status_code == 401
# Jeton inconnu : refusé aussi.
faux = {"Authorization": "Bearer kbt_inconnu"}
assert _call(
client, "create_issue", create_args, headers=faux
).status_code == 401
assert fake_gitea.created == []
# Jeton valide : création et commentaire passent.
r = _call(client, "create_issue", create_args, headers=jeton)
assert r.status_code == 200
assert _result_text(r)[0]["isError"] is False
assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")]
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Vu par le service d'agents",
}, headers=jeton)
assert _result_text(r)[0]["isError"] is False
# Le même jeton n'ouvre jamais l'interface web.
assert client.get(
"/", headers={**jeton, "Accept": "application/json"}
).status_code == 401
# Révocation : effet immédiat sur les écritures.
assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True
assert _call(
client, "create_issue", create_args, headers=jeton
).status_code == 401
assert _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": "x",
}, headers=jeton).status_code == 401
+1 -1
View File
@@ -70,7 +70,7 @@ def test_nav_js_contract(client, fake_gitea):
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
async def fake_collect(only_repo):
async def fake_collect(only_repo, *args, **kwargs):
return [{
"full_name": "Projets/demo",
"counts": {c: 0 for c in
+360
View File
@@ -0,0 +1,360 @@
"""Palier 19 (0.24.0): multi-forges, utilisateurs, espaces,
paramètres en base, archives — P0 à P4 de l'analyse du 2026-10-21."""
import sqlite3
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, forge_crypto, models
from tests.conftest import csrf_from
# ── Migration des anciennes bases (P0) ────────────────────
def test_migration_legacy_schema_gains_scope(tmp_path, monkeypatch):
db = tmp_path / "legacy.db"
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name, issue_number)
);
CREATE TABLE project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name)
);
CREATE TABLE today_picks (
pick_date TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
);
CREATE TABLE board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_column TEXT,
to_column TEXT,
source TEXT NOT NULL DEFAULT 'ui',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO board_state (repo_full_name, issue_number, column_name)
VALUES ('Projets/demo', 1, 'todo');
INSERT INTO project_notes (repo_full_name, note)
VALUES ('Projets/demo', 'note héritée');
"""
)
conn.commit()
conn.close()
monkeypatch.setenv("KANBANTE_DB_PATH", str(db))
models.init_db()
board = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in board["todo"]] == [1]
assert models.get_notes("Projets/demo") == "note héritée"
# La portée par défaut est (1, 1) et les nouvelles tables existent.
assert models.list_connections() == []
assert models.count_users() == 0
# ── Chiffrement des jetons (P0) ───────────────────────────
def test_forge_token_encryption_roundtrip(monkeypatch):
from cryptography.fernet import Fernet
monkeypatch.setenv("KANBANTE_MASTER_KEY", Fernet.generate_key().decode())
forge_crypto._fernet = None # forcer la relecture de la clé
stored = forge_crypto.encrypt_token("ghp_secret")
assert stored != "ghp_secret"
assert forge_crypto.decrypt_token(stored) == "ghp_secret"
assert forge_crypto.mask_token(stored) == "••••••••"
assert forge_crypto.encrypt_token("") == ""
# ── Amorçage (P0/P2/P3) ───────────────────────────────────
@pytest.fixture
def auth_client(tmp_path, monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
yield client
def _login(client, username="bruno", password="motdepasse"):
client.get("/login")
token = client.cookies.get("kanbante_csrf") or ""
return client.post(
"/login",
data={
"username": username,
"password": password,
"next": "/",
"csrf_token": token,
},
follow_redirects=False,
)
def test_bootstrap_creates_legacy_connection_admin_workspace(auth_client):
connections = models.list_connections()
assert len(connections) == 1
assert connections[0]["slug"] == "gitea"
assert connections[0]["kind"] == "gitea"
workspaces = models.list_workspaces()
assert [ws["slug"] for ws in workspaces] == ["principal"]
assert models.workspace_connection_ids(workspaces[0]["id"]) == [
connections[0]["id"]
]
users = models.list_users()
assert len(users) == 1
assert users[0]["username"] == "bruno"
assert users[0]["role"] == "admin"
assert accounts.verify_password("motdepasse", users[0]["password_hash"])
def test_db_login_and_password_change(auth_client):
# Le mot de passe d'environnement a créé le hash en base :
# la connexion passe par la base.
r = _login(auth_client)
assert r.status_code == 303
token = csrf_from(auth_client)
r = auth_client.post(
"/profile/password",
data={
"current_password": "motdepasse",
"new_password": "nouveau-secret",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
auth_client.get("/logout")
# Le nouveau mot de passe fonctionne…
assert _login(auth_client, password="nouveau-secret").status_code == 303
auth_client.get("/logout")
# …et pour un compte membre, l'ancien ne passe plus (pas de
# secours environnement pour les membres).
token = csrf_from(auth_client)
_login(auth_client, password="nouveau-secret")
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "alice",
"password": "alice-pass",
"role": "member",
"display_name": "Alice",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
assert _login(auth_client, "alice", "alice-pass").status_code == 303
token = csrf_from(auth_client)
auth_client.post(
"/profile/password",
data={
"current_password": "alice-pass",
"new_password": "alice-nouveau",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
r = _login(auth_client, "alice", "alice-pass")
assert r.status_code == 401
assert _login(auth_client, "alice", "alice-nouveau").status_code == 303
def test_member_cannot_open_admin(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "bob",
"password": "bob-pass",
"role": "member",
"display_name": "",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
_login(auth_client, "bob", "bob-pass")
assert auth_client.get("/admin").status_code == 403
assert auth_client.get("/admin/connections").status_code == 403
assert auth_client.get("/profile").status_code == 200
def test_disabled_user_session_dies(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "eve",
"password": "eve-pass",
"role": "member",
"display_name": "",
"csrf_token": token,
},
follow_redirects=False,
)
eve_id = models.get_user_by_username("eve")["id"]
auth_client.get("/logout")
_login(auth_client, "eve", "eve-pass")
assert auth_client.get("/profile").status_code == 200
# L'admin désactive Eve pendant que sa session est ouverte.
with TestClient(main.app) as admin_client:
_login(admin_client)
token = csrf_from(admin_client)
admin_client.post(
f"/admin/users/{eve_id}/toggle",
data={"csrf_token": token},
follow_redirects=False,
)
r = auth_client.get(
"/profile", headers={"Accept": "text/html"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
def test_admin_creates_github_connection_encrypted(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/connections",
data={
"kind": "github",
"name": "GitHub perso",
"base_url": "https://api.github.com",
"token": "ghp_secret_token",
"hook_secret": "",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
row = models.get_connection_by_slug("github-perso")
assert row is not None
assert row["kind"] == "github"
assert row["token_encrypted"] != "ghp_secret_token"
assert forge_crypto.decrypt_token(row["token_encrypted"]) == "ghp_secret_token"
page = auth_client.get("/admin/connections")
assert "ghp_secret_token" not in page.text # jamais réaffiché
assert "••••••••" in page.text
# ── Paramètres en base (P3) ───────────────────────────────
def test_settings_panel_overrides_env(auth_client):
from app.config import get_settings
_login(auth_client)
assert get_settings().aging_days == 7
token = csrf_from(auth_client)
auth_client.post(
"/admin/settings",
data={"setting_aging_days": "42", "csrf_token": token},
follow_redirects=False,
)
assert get_settings().aging_days == 42
# Champ vidé = retour à l'environnement.
token = csrf_from(auth_client)
auth_client.post(
"/admin/settings",
data={"setting_aging_days": "", "csrf_token": token},
follow_redirects=False,
)
assert get_settings().aging_days == 7
# ── Espaces et portées (P3) ───────────────────────────────
def test_board_state_is_scoped_per_workspace():
models.move_issue("Projets/demo", 1, "todo", workspace_id=1, connection_id=1)
models.move_issue("Projets/demo", 1, "review", workspace_id=2, connection_id=1)
assert models.get_board_state("Projets/demo", workspace_id=1)["todo"]
board2 = models.get_board_state("Projets/demo", workspace_id=2)
assert board2["review"] and not board2["todo"]
def test_workspace_switch_scopes_dashboard(client, fake_gitea):
# Un espace vide (aucune connexion) ne montre aucun projet.
empty_id = models.create_workspace("vide", "Vide", "#999999")
client.cookies.set("kanbante_workspace", str(empty_id))
page = client.get("/")
assert "/board/Projets/demo" not in page.text
principal = models.list_workspaces()[0]
client.cookies.set("kanbante_workspace", str(principal["id"]))
page = client.get("/")
assert "/board/Projets/demo" in page.text
# ── Archives (P4) ─────────────────────────────────────────
def test_local_archive_hides_and_restores(client, fake_gitea):
token = csrf_from(client)
principal = models.list_workspaces()[0]
assert "/board/Projets/demo" in client.get("/").text
r = client.post(
"/archives/local",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
assert "/board/Projets/demo" not in client.get("/").text
archives = client.get("/archives")
assert "Projets/demo" in archives.text
assert models.list_local_archived(principal["id"])
token = csrf_from(client)
client.post(
"/archives/local",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "0",
"csrf_token": token,
},
follow_redirects=False,
)
assert "/board/Projets/demo" in client.get("/").text
def test_forge_archive_via_page(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/archives/forge",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
repo = next(r for r in fake_gitea.repos if r["full_name"] == "Projets/demo")
assert repo["archived"] is True
+6 -2
View File
@@ -121,6 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
assert names == [
"list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics",
"create_issue", "add_comment",
]
@@ -153,7 +154,7 @@ def test_mcp_unknown_method_and_tool(client):
assert r.json()["error"]["code"] == -32601
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
result = r.json()["result"]
assert result["isError"] is True # read-only server: no such tool
assert result["isError"] is True # aucun outil de ce nom (pas de suppression)
def test_mcp_notification_gets_202(client):
@@ -164,7 +165,10 @@ def test_mcp_notification_gets_202(client):
def test_mcp_info_endpoint(client):
r = client.get("/mcp")
assert r.status_code == 200
assert r.json()["read_only"] is True
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement.
assert r.json()["read_only"] is False
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
# ── Board : gestion des étiquettes ────────────────────────
+20
View File
@@ -0,0 +1,20 @@
"""0.24.2 — #29 : base sombre pour tous les champs de saisie.
Sans règle de fond, les champs hors contexte stylé gardaient le
fond blanc du navigateur (réinitialisation de mot de passe,
textarea des espaces, valeurs du panneau des paramètres).
"""
from pathlib import Path
R = Path(__file__).resolve().parents[1]
def test_base_champs_sombre():
css = (R / "static/css/style.css").read_text(encoding="utf-8")
assert "color-scheme: dark" in css
assert ":where(input:not([type=\"checkbox\"])" in css
assert "textarea, select" in css
assert "background: var(--surface-2);" in css
# la base est en spécificité zéro : les contextes restent maîtres
assert ".palette-input-row input" in css
assert ".login-form input" in css