Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a947747f2 | ||
|
|
5ef6c21756 | ||
|
|
13182f5d53 | ||
|
|
2381ae0b35 | ||
|
|
7b6e72e6ce | ||
|
|
6e31122c46 | ||
|
|
d51ef5a970 | ||
|
|
735d7cdfd0 |
+145
@@ -1,5 +1,150 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
+71
-2
@@ -104,7 +104,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.23.3"
|
||||
APP_VERSION = "0.26.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -179,6 +179,7 @@ class Settings:
|
||||
draco_secret_prev: str = ""
|
||||
sso_machine_key: str = ""
|
||||
sso_url: str = "https://og.dracodev.net"
|
||||
archive_suggest_days: int = 180
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -186,7 +187,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 +237,66 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
+2262
-235
File diff suppressed because it is too large
Load Diff
+766
-57
File diff suppressed because it is too large
Load Diff
+15
-10
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
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?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 = "";
|
||||
@@ -96,3 +101,4 @@
|
||||
};
|
||||
</script>
|
||||
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
|
||||
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.23.3"
|
||||
version = "0.26.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]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1).
|
||||
Composant d'intégration TikTak : le panneau définitif sera vendangé
|
||||
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */
|
||||
.draco-panneau {
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw;
|
||||
background: #fbfbfa; color: #242624; 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.open { transform: translateX(0); }
|
||||
.draco-panneau-hd {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff;
|
||||
}
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: #eef0ea; 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; }
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px;
|
||||
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: #e9ebe4; }
|
||||
.draco-appro {
|
||||
align-self: stretch; background: #fdf6ec; 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-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: #eef0ea; }
|
||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; 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: #6c7268; 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: #eef0ea; border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit;
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: #7c5cd6; 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: #8a8f84; margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; }
|
||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff;
|
||||
}
|
||||
.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: #eef0ea; }
|
||||
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; }
|
||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid #e4e5e0; 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: #6c7268; 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: #6c7268; }
|
||||
.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: #f6e3de; color: #a03a28; }
|
||||
@@ -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;
|
||||
@@ -1636,3 +1658,28 @@ body.has-draco-bar .sidebar { top: 60px; }
|
||||
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; }
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
|
||||
*
|
||||
* 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
|
||||
* GET /api/v1/conversations/… — historique de la conversation
|
||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||
* externes J4 : état de configuration)
|
||||
* POST /api/v1/missions — missions longues (J5) : lancement,
|
||||
* suivi, rapport, annulation
|
||||
* É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;
|
||||
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ }
|
||||
var etatConnu = false;
|
||||
var historiqueCharge = false;
|
||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||
|
||||
function domaine() {
|
||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||
}
|
||||
|
||||
function construire() {
|
||||
root = document.createElement("aside");
|
||||
root.className = "draco-panneau";
|
||||
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||
root.innerHTML =
|
||||
'<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="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' +
|
||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||
'</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]");
|
||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||
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);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
||||
});
|
||||
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function majEtat(cls, texte) {
|
||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||
etatEl.querySelector(".txt").textContent = texte;
|
||||
}
|
||||
|
||||
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 { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
|
||||
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;color:#6c7268'>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 rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
|
||||
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
|
||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
|
||||
"<div class='m-meta'>" + esc(m.executor) +
|
||||
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
|
||||
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + 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 chargerHistorique() {
|
||||
if (historiqueCharge || !convId) return;
|
||||
historiqueCharge = true;
|
||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) {
|
||||
convId = null;
|
||||
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d || !d.messages || !d.messages.length) return;
|
||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||
d.messages.forEach(function (m) {
|
||||
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
|
||||
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
|
||||
}
|
||||
|
||||
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;
|
||||
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ }
|
||||
attente.textContent = d.reply;
|
||||
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();
|
||||
root.classList.add("open");
|
||||
verifierIdentite();
|
||||
chargerHistorique();
|
||||
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
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ────────────────────────
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user