Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13182f5d53 | ||
|
|
2381ae0b35 | ||
|
|
7b6e72e6ce | ||
|
|
6e31122c46 | ||
|
|
d51ef5a970 | ||
|
|
735d7cdfd0 | ||
|
|
f1a05279b6 |
+114
@@ -1,5 +1,119 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.25.0 — 2026-10-21
|
||||||
|
|
||||||
|
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
|
||||||
|
|
||||||
|
Depuis la 0.24.0, tout Kanbante exige une authentification (session
|
||||||
|
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
|
||||||
|
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
|
||||||
|
Kanbante injoignable »). Il manquait une identité machine propre.
|
||||||
|
|
||||||
|
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
|
||||||
|
l'empreinte SHA-256 est conservée ; création, liste et révocation
|
||||||
|
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
|
||||||
|
seule fois à la création.
|
||||||
|
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
|
||||||
|
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
|
||||||
|
web. Révocation et désactivation du compte ont effet immédiat ;
|
||||||
|
`last_used_at` journalise l'usage.
|
||||||
|
- Usage visé : créer un jeton « service-agents » et le poser en
|
||||||
|
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
|
||||||
|
|
||||||
|
## 0.24.2 — 2026-10-21
|
||||||
|
|
||||||
|
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
|
||||||
|
|
||||||
|
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
|
||||||
|
(Administration), la zone « Sélection de projets » (Espaces) et
|
||||||
|
les champs « Valeur du panneau » (Paramètres) s'affichaient en
|
||||||
|
**blanc**. Il n'existait aucune règle de base pour les champs de
|
||||||
|
saisie : seuls les champs couverts par un contexte précis
|
||||||
|
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
|
||||||
|
select seulement) étaient sombres — les textarea et tout champ
|
||||||
|
hors de ces contextes gardaient le fond du navigateur.
|
||||||
|
|
||||||
|
Correctif dans `style.css` : une **règle de base unique en
|
||||||
|
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
|
||||||
|
`--text`, bordure `--border` — pour `input`, `textarea` et
|
||||||
|
`select`, qui comble les vides sans jamais écraser les règles de
|
||||||
|
contexte existantes ; plus `color-scheme: dark` pour les
|
||||||
|
contrôles natifs. Styles calculés vérifiés sur les trois pages
|
||||||
|
(fond identique aux champs de référence, palette de commandes
|
||||||
|
inchangée).
|
||||||
|
|
||||||
|
|
||||||
|
## 0.24.0 — 2026-10-21
|
||||||
|
|
||||||
|
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
|
||||||
|
|
||||||
|
Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées
|
||||||
|
selon les recommandations). Aucune configuration obligatoire :
|
||||||
|
au premier démarrage, la connexion Gitea historique est créée
|
||||||
|
depuis l'environnement, l'espace **Principal** la contient, et
|
||||||
|
le compte d'environnement devient l'administrateur initial
|
||||||
|
(mot de passe haché en base). Migration automatique des bases
|
||||||
|
existantes (portées (1, 1) partout).
|
||||||
|
|
||||||
|
- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou
|
||||||
|
**GitHub**, nouveau client complet : dépôts, issues, étiquettes
|
||||||
|
par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en
|
||||||
|
base, jamais réaffiché (masque ••••). Registre de clients,
|
||||||
|
instantanés par connexion. Webhooks par connexion
|
||||||
|
(`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du
|
||||||
|
webhook historique.
|
||||||
|
- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles
|
||||||
|
admin/membre, activation/désactivation (une session d'un
|
||||||
|
compte désactivé meurt aussitôt), journal d'audit admin.
|
||||||
|
Connexion par la base ; les identifiants d'environnement
|
||||||
|
restent un **secours** pour le compte opérateur. Liaison
|
||||||
|
**draco par utilisateur** (le pont SSO lie et ouvre la
|
||||||
|
session du bon compte ; sémantique mono-utilisateur 0.23
|
||||||
|
conservée tant qu'il n'y a qu'un compte). Page **/profile** :
|
||||||
|
infos, mot de passe, déliaison draco, connexions personnelles.
|
||||||
|
- **Espaces de travail (P3)** — l'unité de présentation :
|
||||||
|
connexions attachées, membres (admin d'espace / membre /
|
||||||
|
lecteur), sélection explicite de projets optionnelle.
|
||||||
|
**Board, notes, épingles du jour, journal : scopés par
|
||||||
|
espace** (le même projet peut être rangé différemment dans
|
||||||
|
deux espaces). Sélecteur d'espace dans l'en-tête (visible
|
||||||
|
dès qu'on a accès à plusieurs espaces), badge de connexion
|
||||||
|
sur les projets quand l'espace en a plusieurs. Panneau
|
||||||
|
**Paramètres** : l'environnement amorce, le panneau
|
||||||
|
gouverne — surcharges en base pour les réglages courants
|
||||||
|
(fenêtre d'activité, TTL, seuils zombies/âge, horaires des
|
||||||
|
rituels, seuil d'archivage…), provenance affichée.
|
||||||
|
- **Archives (P4)** — page **/archives** : archive **locale**
|
||||||
|
(masque le projet dans l'espace, forge intacte, réversible),
|
||||||
|
archivage **sur la forge** (confirmé, journalisé), purge de
|
||||||
|
l'état local de l'espace, export du récapitulatif Markdown,
|
||||||
|
file **« À archiver ? »** (inactifs depuis
|
||||||
|
`archive_suggest_days`, 180 j par défaut) avec archivage
|
||||||
|
local en lot. Les archives sortent du tableau de bord ;
|
||||||
|
l'onglet Archivés y gagne un lien « Gérer ».
|
||||||
|
|
||||||
|
### Limites assumées de cette version
|
||||||
|
|
||||||
|
- Les rituels d'arrière-plan (briefing, digest, règles,
|
||||||
|
serveur MCP) travaillent sur la portée historique
|
||||||
|
(connexion principale, espace Principal).
|
||||||
|
- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de
|
||||||
|
chiffrement est générée une fois et gardée en base.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.23.3 — 2026-10-21
|
||||||
|
|
||||||
|
### Correctifs — barre commune v0.1.4 (retours de Bruno)
|
||||||
|
|
||||||
|
- **Assets de la barre versionnés** (`?v=` dans l'adresse) : sans
|
||||||
|
ça, le navigateur — surtout la webview de l'app bureau ObsiGate —
|
||||||
|
gardait l'ancien JavaScript et le menu du ✦ ne s'ouvrait pas,
|
||||||
|
alors que le serveur servait déjà le bon fichier.
|
||||||
|
- **Palier mobile élargi à 900 px** (commons v0.1.4) : entre 700
|
||||||
|
et 900 px, la barre complète ne tenait pas alors que Kanbante
|
||||||
|
est déjà en mise en page mobile — d'où le look différent des
|
||||||
|
autres apps sur la capture de Bruno. Décalage du cadre aligné.
|
||||||
|
|
||||||
## 0.23.2 — 2026-10-21
|
## 0.23.2 — 2026-10-21
|
||||||
|
|
||||||
### Ajouté — menu des applications (barre commune v0.1.3)
|
### Ajouté — menu des applications (barre commune v0.1.3)
|
||||||
|
|||||||
@@ -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
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.23.2"
|
APP_VERSION = "0.25.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -179,6 +179,7 @@ class Settings:
|
|||||||
draco_secret_prev: str = ""
|
draco_secret_prev: str = ""
|
||||||
sso_machine_key: str = ""
|
sso_machine_key: str = ""
|
||||||
sso_url: str = "https://og.dracodev.net"
|
sso_url: str = "https://og.dracodev.net"
|
||||||
|
archive_suggest_days: int = 180
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -186,7 +187,13 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
def get_settings() -> 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(
|
return Settings(
|
||||||
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
|
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
|
||||||
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
||||||
@@ -230,4 +237,66 @@ def get_settings() -> Settings:
|
|||||||
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
|
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
|
||||||
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
||||||
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
|
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,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
"capabilities": {},
|
"capabilities": {},
|
||||||
"clientInfo": {"name": "kanbante", "version": "0.22.1"},
|
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
|
||||||
},
|
},
|
||||||
req_id=1,
|
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:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -51,11 +62,15 @@ class GiteaClient:
|
|||||||
self.base_url = (base_url or settings.gitea_url).rstrip("/")
|
self.base_url = (base_url or settings.gitea_url).rstrip("/")
|
||||||
self.token = settings.gitea_token if token is None else token
|
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.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 = {"Accept": "application/json"}
|
||||||
|
headers.update(self._extra_headers())
|
||||||
if self.token:
|
if self.token:
|
||||||
headers["Authorization"] = f"token {self.token}"
|
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
|
||||||
self.client = httpx.AsyncClient(
|
self.client = httpx.AsyncClient(
|
||||||
base_url=f"{self.base_url}/api/v1",
|
base_url=f"{self.base_url}{self.api_suffix}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=15.0,
|
timeout=15.0,
|
||||||
transport=transport,
|
transport=transport,
|
||||||
@@ -69,6 +84,9 @@ class GiteaClient:
|
|||||||
# label and the persisted `snapshots` table.
|
# label and the persisted `snapshots` table.
|
||||||
self._snapshot_times: dict[str, str] = {}
|
self._snapshot_times: dict[str, str] = {}
|
||||||
|
|
||||||
|
def _extra_headers(self) -> dict:
|
||||||
|
return {}
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|
||||||
@@ -120,6 +138,9 @@ class GiteaClient:
|
|||||||
|
|
||||||
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
# ── 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:
|
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||||
"""Write-through to the SQLite snapshots table.
|
"""Write-through to the SQLite snapshots table.
|
||||||
|
|
||||||
@@ -129,7 +150,7 @@ class GiteaClient:
|
|||||||
try:
|
try:
|
||||||
from app import models
|
from app import models
|
||||||
|
|
||||||
models.save_snapshot(key, value)
|
models.save_snapshot(self._prefixed(key), value)
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
self._snapshot_times[key] = datetime.datetime.now(
|
self._snapshot_times[key] = datetime.datetime.now(
|
||||||
@@ -157,12 +178,20 @@ class GiteaClient:
|
|||||||
logger.warning("snapshot hydration failed: %s", exc)
|
logger.warning("snapshot hydration failed: %s", exc)
|
||||||
return 0
|
return 0
|
||||||
stale_at = time.monotonic() - self.cache_ttl - 1.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._cache[key] = (stale_at, payload)
|
||||||
self._snapshot_times[key] = updated_at
|
self._snapshot_times[key] = updated_at
|
||||||
if snapshots:
|
count += 1
|
||||||
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
if count:
|
||||||
return len(snapshots)
|
logger.info("hydrated %d snapshot(s) from SQLite", count)
|
||||||
|
return count
|
||||||
|
|
||||||
def latest_snapshot_at(self) -> str | None:
|
def latest_snapshot_at(self) -> str | None:
|
||||||
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||||
@@ -487,6 +516,17 @@ class GiteaClient:
|
|||||||
self._cache_clear()
|
self._cache_clear()
|
||||||
return r.json()
|
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(
|
async def create_branch(
|
||||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||||
) -> dict:
|
) -> 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()
|
||||||
+2153
-226
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)
|
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.
|
"""Periodic refresher, started by the app lifespan.
|
||||||
|
|
||||||
Sleeps briefly first: hydration already serves the pages,
|
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)
|
await asyncio.sleep(10)
|
||||||
while True:
|
while True:
|
||||||
try:
|
interval = get_settings().snapshot_interval
|
||||||
await refresh_snapshots(gitea, classify_repos)
|
if interval <= 0:
|
||||||
except Exception as exc:
|
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
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)
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.3
|
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
|
||||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||||
passe par une nouvelle version de commons.
|
passe par une nouvelle version de commons.
|
||||||
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
|
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
|
||||||
Inclus par _header.html (toutes les pages applicatives ; ni le
|
Inclus par _header.html (toutes les pages applicatives ; ni le
|
||||||
wallboard ni l'écran de connexion, volontaires). #}
|
wallboard ni l'écran de connexion, volontaires). #}
|
||||||
<link rel="stylesheet" href="/static/css/draco-barre.css">
|
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
|
||||||
<script>document.body.classList.add('has-draco-bar');</script>
|
<script>document.body.classList.add('has-draco-bar');</script>
|
||||||
<header class="draco-bar" data-current="kanbante">
|
<header class="draco-bar" data-current="kanbante">
|
||||||
<div class="draco-bar-zone draco-bar-gauche">
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
@@ -95,4 +95,4 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script defer src="/static/js/draco-barre.js"></script>
|
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
|
||||||
|
|||||||
@@ -28,9 +28,20 @@
|
|||||||
("outils", "Outils", [
|
("outils", "Outils", [
|
||||||
("search", "/search", "🔍", "Recherche"),
|
("search", "/search", "🔍", "Recherche"),
|
||||||
("capture", "/capture", "⚡", "Capture"),
|
("capture", "/capture", "⚡", "Capture"),
|
||||||
|
("archives", "/archives", "🗄️", "Archives"),
|
||||||
("wall", "/wall", "🖥️", "Wallboard"),
|
("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") %}
|
{% set active_group = namespace(key="principal") %}
|
||||||
{% for gkey, group, links in nav_groups %}
|
{% for gkey, group, links in nav_groups %}
|
||||||
{% for key, href, icon, label in links %}
|
{% for key, href, icon, label in links %}
|
||||||
@@ -49,7 +60,7 @@
|
|||||||
localStorage.removeItem('kanbante-nav-closed');
|
localStorage.removeItem('kanbante-nav-closed');
|
||||||
var openGroup = {{ active_group.key | tojson }};
|
var openGroup = {{ active_group.key | tojson }};
|
||||||
var stored = localStorage.getItem('kanbante-nav-open');
|
var stored = localStorage.getItem('kanbante-nav-open');
|
||||||
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
|
var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin'];
|
||||||
if ({{ (active_nav != "") | tojson }}) {
|
if ({{ (active_nav != "") | tojson }}) {
|
||||||
localStorage.setItem('kanbante-nav-open', openGroup);
|
localStorage.setItem('kanbante-nav-open', openGroup);
|
||||||
} else if (stored === '') {
|
} 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-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>
|
<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>
|
</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">
|
<nav class="side-nav">
|
||||||
{% for gkey, group, links in nav_groups %}
|
{% for gkey, group, links in nav_groups %}
|
||||||
<div class="side-section" data-group="{{ gkey }}">
|
<div class="side-section" data-group="{{ gkey }}">
|
||||||
@@ -95,6 +117,9 @@
|
|||||||
{% if data_freshness %}
|
{% 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>
|
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
|
||||||
{% endif %}
|
{% 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>
|
<span class="side-version side-label">v{{ version }}</span>
|
||||||
{% if auth_enabled %}
|
{% if auth_enabled %}
|
||||||
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
<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 === '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 === '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>
|
<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>
|
||||||
<div class="search-bar toolbar-search">
|
<div class="search-bar toolbar-search">
|
||||||
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
|
<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())">
|
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="org-tag">{{ repo.owner.login }}</span>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% 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]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.23.2"
|
version = "0.25.0"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"httpx>=0.28",
|
"httpx>=0.28",
|
||||||
"python-dotenv>=1.0",
|
"python-dotenv>=1.0",
|
||||||
"python-multipart>=0.0.18",
|
"python-multipart>=0.0.18",
|
||||||
|
"cryptography>=42",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
|
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
|
||||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21).
|
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||||
|
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
|
||||||
Jetons repris du prototype validé Console v7.2.
|
Jetons repris du prototype validé Console v7.2.
|
||||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||||
@@ -143,14 +144,18 @@
|
|||||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||||
.draco-bar-droite { justify-content: flex-end; }
|
.draco-bar-droite { justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 900px) {
|
||||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||||
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
||||||
disparaissent : chaque app garde ses contrôles natifs (barre
|
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||||
|
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||||
restaient affichés et la barre débordait sur téléphone.) */
|
restaient affichés et la barre débordait sur téléphone.
|
||||||
|
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
|
||||||
|
barre complète ne tenait pas (fenêtres réduites, tablettes,
|
||||||
|
webviews) alors que les apps sont déjà en mise en page mobile.) */
|
||||||
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
|
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
|
||||||
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
|
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
|
||||||
.draco-apptab { display: none; }
|
.draco-apptab { display: none; }
|
||||||
|
|||||||
+48
-1
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
button, input, select, textarea { font: inherit; }
|
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 ─────────────────────────────── */
|
||||||
.app-header, header {
|
.app-header, header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -1632,7 +1654,32 @@ a.ms-title:hover { text-decoration: underline; }
|
|||||||
body.has-draco-bar { padding-top: 60px; }
|
body.has-draco-bar { padding-top: 60px; }
|
||||||
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
|
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
|
||||||
body.has-draco-bar .sidebar { top: 60px; }
|
body.has-draco-bar .sidebar { top: 60px; }
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 900px) {
|
||||||
body.has-draco-bar { padding-top: 52px; }
|
body.has-draco-bar { padding-top: 52px; }
|
||||||
body.has-draco-bar .sidebar { 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; }
|
||||||
|
|||||||
+14
-1
@@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
|
|||||||
class FakeGitea:
|
class FakeGitea:
|
||||||
"""Drop-in async replacement for GiteaClient used by the API tests."""
|
"""Drop-in async replacement for GiteaClient used by the API tests."""
|
||||||
|
|
||||||
|
kind = "gitea"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
self.connection_id = 1
|
||||||
|
self.connection_slug = "gitea"
|
||||||
|
self.key_prefix = ""
|
||||||
self.starred = set()
|
self.starred = set()
|
||||||
self.repos = [
|
self.repos = [
|
||||||
_repo("demo", now - timedelta(days=1)),
|
_repo("demo", now - timedelta(days=1)),
|
||||||
@@ -136,9 +141,17 @@ class FakeGitea:
|
|||||||
results.append({**issue, "repository": {"full_name": full_name}})
|
results.append({**issue, "repository": {"full_name": full_name}})
|
||||||
return results
|
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):
|
async def delete_label(self, owner, repo, label_id):
|
||||||
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
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]
|
del labels[name]
|
||||||
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
|
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
|
||||||
@@ -31,6 +31,9 @@ def test_menu_des_applications_par_la_marque(client):
|
|||||||
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
|
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
|
||||||
accueil = client.get("/")
|
accueil = client.get("/")
|
||||||
assert 'data-act="apps"' in accueil.text
|
assert 'data-act="apps"' in accueil.text
|
||||||
|
# 0.23.3 : assets versionnés, sinon le cache garde l'ancien JS.
|
||||||
|
assert "draco-barre.css?v=" in accueil.text
|
||||||
|
assert "draco-barre.js?v=" in accueil.text
|
||||||
js = client.get("/static/js/draco-barre.js").text
|
js = client.get("/static/js/draco-barre.js").text
|
||||||
assert "draco-apps-menu" in js
|
assert "draco-apps-menu" in js
|
||||||
css = client.get("/static/css/draco-barre.css").text
|
css = client.get("/static/css/draco-barre.css").text
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -70,7 +70,7 @@ def test_nav_js_contract(client, fake_gitea):
|
|||||||
|
|
||||||
|
|
||||||
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
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 [{
|
return [{
|
||||||
"full_name": "Projets/demo",
|
"full_name": "Projets/demo",
|
||||||
"counts": {c: 0 for c in
|
"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
|
||||||
@@ -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