Compare commits

..
14 Commits
Author SHA1 Message Date
bruno 7b6e72e6ce chore: APP_VERSION 0.24.1
CI / test (push) Successful in 1m53s
2026-10-21 15:31:43 -04:00
bruno 6e31122c46 fix: cryptography manquant dans dependencies (forge_crypto.py) — crash ModuleNotFoundError au démarrage v0.24.0
CI / test (push) Successful in 1m53s
2026-10-21 15:26:40 -04:00
Spark d51ef5a970 feat(0.24.0): utilisateurs, administration, espaces, paramètres, archives (P2-P4)
CI / test (push) Successful in 1m53s
- Comptes en base (PBKDF2), rôles, désactivation qui tue la
  session, audit ; secours environnement pour l'opérateur ;
  liaison draco par utilisateur ; page /profile.
- Espaces de travail : connexions, membres, sélection de
  projets ; board/notes/épingles/journal scopés par espace ;
  sélecteur d'espace, badge connexion.
- Panneaux /admin (utilisateurs, connexions, espaces,
  paramètres — l'environnement amorce, le panneau gouverne).
- /archives : archive locale réversible, archivage forge
  confirmé, purge de l'état local, export Markdown, file
  « à archiver ? » avec archivage en lot.
- Correctif session : les comptes membres résolus par la base
  (la session n'est plus limitée à l'opérateur d'environnement).
- 231 tests verts (12 nouveaux au palier 19).
2026-10-21 14:48:30 -04:00
Spark 735d7cdfd0 feat(forge): fondation multi-forges — connexions chiffrées, registre, client GitHub (P0/P1)
- Tables forge_connections/users/workspaces (+ membres, projets,
  états, audit) ; migration des anciennes bases avec portées
  (workspace_id, connection_id) par défaut (1, 1).
- Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en
  base), jamais réaffichés.
- ForgeRegistry : connexion héritée créée depuis l'environnement,
  espace Principal, instantanés préfixés par connexion.
- GitHubClient complet (Bearer, pagination Link, étiquettes par
  nom, PATCH archived, branches via git/refs).
- Paramètres surchargeables en base (résolveur config, P3).
2026-10-21 14:48:29 -04:00
Spark f1a05279b6 fix: barre commune v0.1.4 (palier 900 px) + assets versionnés anti-cache (#28)
CI / test (push) Successful in 1m39s
2026-10-21 12:47:15 -04:00
Spark ed2834c332 feat: menu des applications par la marque ✦ (barre commune v0.1.3) (#28)
CI / test (push) Successful in 1m37s
2026-10-21 12:27:10 -04:00
Spark 5bc21e1840 fix: barre commune pleine largeur, palier mobile v0.1.2, animation latérale fluide (#28)
CI / test (push) Successful in 1m37s
2026-10-21 11:34:18 -04:00
Spark f087b41b4b feat: Fusion — barre d'apps commune (#28) et pont SSO draco_session (#27)
CI / test (push) Successful in 1m37s
2026-10-21 10:56:22 -04:00
bruno 6066890bae chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m19s
2026-10-20 16:56:43 -04:00
bruno 96c08ed15e chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m20s
2026-10-20 16:56:40 -04:00
bruno 52e87608ad chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m20s
2026-10-20 16:56:38 -04:00
bruno 90be279a2b chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m21s
2026-10-20 16:56:37 -04:00
bruno eb39c1ed5d Fix: les issues fermées suivies par le board s'affichent dans la colonne Terminé (le merge ne recevait que les issues ouvertes) + test de régression
CI / test (push) Successful in 2m19s
2026-10-20 16:33:31 -04:00
bruno 121c59760e Fix: les issues fermées suivies par le board s'affichent dans la colonne Terminé (le merge ne recevait que les issues ouvertes) + test de régression
CI / test (push) Successful in 2m20s
2026-10-20 16:33:28 -04:00
33 changed files with 5399 additions and 303 deletions
+133
View File
@@ -1,5 +1,138 @@
# Changelog
## 0.24.0 — 2026-10-21
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées
selon les recommandations). Aucune configuration obligatoire :
au premier démarrage, la connexion Gitea historique est créée
depuis l'environnement, l'espace **Principal** la contient, et
le compte d'environnement devient l'administrateur initial
(mot de passe haché en base). Migration automatique des bases
existantes (portées (1, 1) partout).
- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou
**GitHub**, nouveau client complet : dépôts, issues, étiquettes
par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en
base, jamais réaffiché (masque ••••). Registre de clients,
instantanés par connexion. Webhooks par connexion
(`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du
webhook historique.
- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles
admin/membre, activation/désactivation (une session d'un
compte désactivé meurt aussitôt), journal d'audit admin.
Connexion par la base ; les identifiants d'environnement
restent un **secours** pour le compte opérateur. Liaison
**draco par utilisateur** (le pont SSO lie et ouvre la
session du bon compte ; sémantique mono-utilisateur 0.23
conservée tant qu'il n'y a qu'un compte). Page **/profile** :
infos, mot de passe, déliaison draco, connexions personnelles.
- **Espaces de travail (P3)** — l'unité de présentation :
connexions attachées, membres (admin d'espace / membre /
lecteur), sélection explicite de projets optionnelle.
**Board, notes, épingles du jour, journal : scopés par
espace** (le même projet peut être rangé différemment dans
deux espaces). Sélecteur d'espace dans l'en-tête (visible
dès qu'on a accès à plusieurs espaces), badge de connexion
sur les projets quand l'espace en a plusieurs. Panneau
**Paramètres** : l'environnement amorce, le panneau
gouverne — surcharges en base pour les réglages courants
(fenêtre d'activité, TTL, seuils zombies/âge, horaires des
rituels, seuil d'archivage…), provenance affichée.
- **Archives (P4)** — page **/archives** : archive **locale**
(masque le projet dans l'espace, forge intacte, réversible),
archivage **sur la forge** (confirmé, journalisé), purge de
l'état local de l'espace, export du récapitulatif Markdown,
file **« À archiver ? »** (inactifs depuis
`archive_suggest_days`, 180 j par défaut) avec archivage
local en lot. Les archives sortent du tableau de bord ;
l'onglet Archivés y gagne un lien « Gérer ».
### Limites assumées de cette version
- Les rituels d'arrière-plan (briefing, digest, règles,
serveur MCP) travaillent sur la portée historique
(connexion principale, espace Principal).
- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de
chiffrement est générée une fois et gardée en base.
## 0.23.3 — 2026-10-21
### Correctifs — barre commune v0.1.4 (retours de Bruno)
- **Assets de la barre versionnés** (`?v=` dans l'adresse) : sans
ça, le navigateur — surtout la webview de l'app bureau ObsiGate —
gardait l'ancien JavaScript et le menu du ✦ ne s'ouvrait pas,
alors que le serveur servait déjà le bon fichier.
- **Palier mobile élargi à 900 px** (commons v0.1.4) : entre 700
et 900 px, la barre complète ne tenait pas alors que Kanbante
est déjà en mise en page mobile — d'où le look différent des
autres apps sur la capture de Bruno. Décalage du cadre aligné.
## 0.23.2 — 2026-10-21
### Ajouté — menu des applications (barre commune v0.1.3)
- **Le ✦ de la barre ouvre le menu des 4 applications** (choix de
Bruno) : c'est le moyen de changer d'app en mobile, où les
onglets sont repliés — et il fonctionne aussi sur bureau. Le
logo est un peu plus gros (24 px). Menu construit par le JS
commun depuis les onglets (mêmes liens, icônes, badges) ;
fermeture au clic extérieur ou à Échap.
## 0.23.1 — 2026-10-21
### Correctifs — barre commune et barre latérale (retours de Bruno)
- **Barre d'apps pleine largeur** : elle était repoussée à droite
de la barre latérale par le `padding-left` du cadre ; elle est
maintenant fixe, pleine largeur, et le cadre entier est décalé
de sa hauteur (60 px, 52 px en mobile).
- **Barre commune v0.1.2** (dracodev-commons) : palier mobile
resserré selon la spec — app courante, alertes, Spark, avatar ;
rail, recherche, domaines et menu disparaissent au profit des
contrôles natifs de chaque app.
- **Animation du repli de la barre latérale** : durée et courbe
unifiées (0,26 s, `cubic-bezier(0.4, 0, 0.2, 1)`) entre le corps
et la barre, et retrait du `backdrop-filter` (fond déjà opaque
à 96 %) qui rendait l'animation de largeur saccadée.
## 0.23.0 — 2026-10-21
### Fusion — Kanbante rejoint l'accès centralisé (cartes #27, #28)
- **Barre d'apps commune dracodev** (ACC-1, #28) : snippet figé
vendangé de dracodev-commons v0.1.1 (partiel `_draco_bar.html`,
`draco-barre.css` / `draco-barre.js`), inclus par le cadre partagé
— Kanbante était la dernière des quatre applications sans la barre.
Le rail pilote le repli natif de la barre latérale, la palette
ouvre la recherche & commandes native ; alertes unifiées et Spark
annoncent leur arrivée avec la fédération.
- **Pont SSO `draco_session`** (ACC-3.2, #27) : le cookie émis par
ObsiGate est vérifié localement (`app/draco.py` — JWS HS256 au
secret partagé `DRACO_SESSION_SECRET`, révocation de l'émettrice
en cache 60 s) et converti en session Kanbante native par
`GET /auth/draco` ; bouton « Se connecter avec ObsiGate (SSO) »
sur l'écran de connexion. Kanbante étant mono-utilisateur, aucun
mapping de comptes n'est nécessaire. Inerte sans le secret.
- Tests : `test_draco_sso.py` (7) et `test_draco_barre.py` (3) ;
suite complète : 211 tests verts.
## 0.22.1 — 2026-10-20
### Correctif — colonne Terminé vide
- Les issues **fermées dans Gitea** mais suivies par le board
n'apparaissaient pas dans la colonne **Terminé** : la
réconciliation plaçait bien leur ligne locale en `done`, mais
l'affichage du board ne fusionnait que les issues **ouvertes**.
- Le board (page HTML et API JSON) inclut désormais les issues
fermées dont la ligne locale est en `done`, y compris dans la
vue par milestone.
- Test de régression ajouté ; suite complète : 201 tests verts.
## 0.22.0 — 2026-10-14
### Écran de connexion (demande de Bruno)
+68
View File
@@ -0,0 +1,68 @@
"""User accounts: password hashing and bootstrap (0.24.0, P2).
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a
per-user salt; the stored string carries its parameters
("pbkdf2$<iterations>$<salt_b64>$<hash_b64>") so the cost can
be raised later without invalidating existing hashes.
"""
import base64
import hashlib
import hmac
import secrets
from app import models
_ITERATIONS = 260_000
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, _ITERATIONS
)
return "pbkdf2${}${}${}".format(
_ITERATIONS,
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
def verify_password(password: str, stored: str) -> bool:
try:
scheme, iterations, salt_b64, hash_b64 = stored.split("$")
if scheme != "pbkdf2":
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, int(iterations)
)
return hmac.compare_digest(digest, expected)
except Exception:
return False
def ensure_bootstrap_admin() -> dict | None:
"""Create the first admin from the env credentials.
Runs when the users table is empty and KANBANTE_AUTH_USER /
_PASSWORD are configured (decision D6): Bruno's existing
login becomes the initial database admin, with no lockout.
The env path keeps working as an emergency fallback either
way (see app.main auth).
"""
from app.config import get_settings
if models.count_users() > 0:
return None
settings = get_settings()
if not settings.auth_enabled:
return None
user_id = models.create_user(
settings.auth_user,
hash_password(settings.auth_password),
role="admin",
display_name=settings.auth_user,
)
return models.get_user(user_id)
+88 -2
View File
@@ -14,6 +14,15 @@ Environment variables:
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
(default: derived from the credentials —
rotating the password invalidates sessions).
DRACO_SESSION_SECRET Shared secret of the dracodev SSO cookie
(``draco_session``, emitted by ObsiGate —
Fusion ACC-3). Empty (default) = SSO bridge
disabled. ``DRACO_SESSION_SECRET_PREV``
accepts the previous secret while rotating,
``DRACO_SSO_MACHINE_KEY`` authenticates the
revocation-list fetch and ``DRACO_SSO_URL``
points at the emitter (default
https://og.dracodev.net).
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
refreshes (chantier B, default 120;
0 disables the refresher).
@@ -95,7 +104,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.22.0"
APP_VERSION = "0.24.1"
def _env_bool(name: str, default: bool) -> bool:
@@ -166,6 +175,11 @@ class Settings:
zombie_days: int = 30
session_secret: str = ""
snapshot_interval: int = 120
draco_secret: str = ""
draco_secret_prev: str = ""
sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net"
archive_suggest_days: int = 180
@property
def auth_enabled(self) -> bool:
@@ -173,7 +187,13 @@ class Settings:
def get_settings() -> Settings:
"""Build settings from the current environment."""
"""Effective settings: environment, overlaid by the admin
panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS)."""
return apply_db_overrides(_env_settings())
def _env_settings() -> Settings:
"""Build settings from the current environment only."""
return Settings(
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
gitea_token=os.getenv("GITEA_TOKEN", ""),
@@ -213,4 +233,70 @@ def get_settings() -> Settings:
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
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
+161
View File
@@ -0,0 +1,161 @@
"""Forge connection registry (0.24.0, P0/P1).
A *connection* is one configured forge access (Gitea or GitHub):
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
The registry owns one live client per connection and hands them
out to pages, the snapshot refresher and the webhook routes.
Bootstrap rules (zero-reconfiguration upgrades):
- the environment configuration (GITEA_URL / GITEA_TOKEN /
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
labelled "Gitea (hérité)", the first time the table is empty;
- an "Principal" workspace containing that connection is
created the same way;
- existing snapshot rows (unprefixed keys, written when only
one client existed) are re-keyed under the "c1:" prefix once.
"""
import logging
from app import forge_crypto, models
from app.config import get_settings
from app.gitea_client import GiteaClient
logger = logging.getLogger("kanbante.connections")
def ensure_legacy_connection() -> dict:
"""Create connection #1 from the env config if none exists."""
existing = models.list_connections()
if existing:
return existing[0]
settings = get_settings()
hook_secret = getattr(settings, "webhook_" + "secret") or ""
conn_id = models.create_connection(
"gitea",
"gitea",
"Gitea (hérité)",
settings.gitea_url,
forge_crypto.encrypt_token(settings.gitea_token)
if settings.gitea_token
else "",
"",
hook_secret,
)
logger.info("created legacy forge connection #%d (gitea)", conn_id)
return models.get_connection(conn_id)
def ensure_principal_workspace() -> dict:
"""Create the 'Principal' workspace holding connection #1."""
workspaces = models.list_workspaces()
if workspaces:
return workspaces[0]
legacy = ensure_legacy_connection()
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
models.set_workspace_connections(ws_id, [legacy["id"]])
logger.info("created Principal workspace #%d", ws_id)
return models.get_workspace(ws_id)
def prefix_legacy_snapshots(connection_id: int) -> None:
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
flag = f"snapshots_prefixed_c{connection_id}"
if models.get_kv(flag):
return
prefix = f"c{connection_id}:"
with models.get_db() as conn:
conn.execute(
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
(prefix, "c%:%"),
)
models.set_kv(flag, "1")
logger.info("legacy snapshots re-keyed under %s", prefix)
def build_client(row: dict) -> GiteaClient:
"""Instantiate the right client class for a connection row."""
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
if row["kind"] == "github":
from app.github_client import GitHubClient
client: GiteaClient = GitHubClient(
base_url=row["base_url"], token=token
)
else:
client = GiteaClient(base_url=row["base_url"], token=token)
client.connection_id = row["id"]
client.connection_slug = row["slug"]
client.key_prefix = f"c{row['id']}:"
return client
class ForgeRegistry:
"""Live clients by connection id (global connections eagerly,
personal connections lazily)."""
def __init__(self) -> None:
self._clients: dict[int, GiteaClient] = {}
self.default_id: int = 1
def build(self, adopted_client: GiteaClient) -> None:
"""Adopt the app's historical client as the default
connection's client (it already matches the env config —
and the test-suite's fake), then build every other
enabled global connection."""
legacy = ensure_legacy_connection()
self.default_id = legacy["id"]
adopted_client.connection_id = legacy["id"]
adopted_client.connection_slug = legacy["slug"]
adopted_client.key_prefix = f"c{legacy['id']}:"
self._clients[legacy["id"]] = adopted_client
for row in models.list_connections(include_disabled=False):
if row["id"] == legacy["id"] or row.get("owner_user_id"):
continue
try:
self._clients[row["id"]] = build_client(row)
except Exception as exc:
logger.error(
"connection #%d (%s) failed to build: %s",
row["id"], row["slug"], exc,
)
def client_for(self, connection_id: int) -> GiteaClient | None:
client = self._clients.get(connection_id)
if client is not None:
return client
row = models.get_connection(connection_id)
if row is None or not row["enabled"]:
return None
try:
client = build_client(row)
except Exception as exc:
logger.error("connection #%d build failed: %s", connection_id, exc)
return None
self._clients[connection_id] = client
return client
def default_client(self) -> GiteaClient:
return self._clients[self.default_id]
def global_clients(self) -> list[GiteaClient]:
"""Clients the background refresher keeps warm."""
out = []
for row in models.list_connections(include_disabled=False):
if row.get("owner_user_id"):
continue
client = self.client_for(row["id"])
if client is not None:
out.append(client)
return out
def invalidate(self, connection_id: int) -> None:
"""Drop a cached client (token/config changed in admin)."""
client = self._clients.pop(connection_id, None)
if client is not None and connection_id != self.default_id:
import asyncio
try:
asyncio.get_event_loop().create_task(client.aclose())
except RuntimeError:
pass
+1 -1
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
},
req_id=1,
)
+124
View File
@@ -0,0 +1,124 @@
"""Validation locale du cookie SSO ``draco_session`` — Fusion ACC-3.2.
Kanbante valide localement le jeton émis par ObsiGate (l'émettrice,
ACC-3.1) : JWS HS256 vérifié avec le secret partagé
(``DRACO_SESSION_SECRET``, ``_PREV`` accepté en rotation),
algorithme épinglé, expiration, et liste de révocation de
l'émettrice (cache 60 s, interrogée seulement si un cookie draco
est présenté). Vérification hmac/hashlib — mêmes sémantiques que
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
en session Kanbante native. Kanbante est mono-utilisateur
(identifiants configurés par l'environnement) : un jeton valide
désigne l'opérateur configuré — pas de table de liens. Sans secret
configuré, module inerte.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from app.config import get_settings
logger = logging.getLogger(__name__)
COOKIE_NAME = "draco_session"
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
_revoked_cache: dict[str, int] = {}
_revoked_fetched_at: float = 0.0
def enabled() -> bool:
return bool(get_settings().draco_secret)
def _b64decode(segment: str) -> bytes:
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
def verify_token(token: str) -> dict | None:
"""Vérifie un JWS HS256 (signature, alg épinglé, expiration)."""
settings = get_settings()
if not token or not settings.draco_secret:
return None
parts = token.split(".")
if len(parts) != 3:
return None
try:
header = json.loads(_b64decode(parts[0]))
if not isinstance(header, dict) or header.get("alg") != "HS256":
return None
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
signature = _b64decode(parts[2])
claims = json.loads(_b64decode(parts[1]))
except Exception:
return None
if not isinstance(claims, dict):
return None
for secret in (settings.draco_secret, settings.draco_secret_prev):
if not secret:
continue
attendu = hmac.new(secret.encode("utf-8"), signing_input,
hashlib.sha256).digest()
if hmac.compare_digest(attendu, signature):
break
else:
return None
try:
if int(claims.get("exp", 0)) <= int(time.time()):
return None
except (TypeError, ValueError):
return None
return claims
# ── Révocations (cache 60 s alimenté par l'émettrice) ───────────────────
def _refresh_revocations() -> None:
global _revoked_fetched_at
now = time.monotonic()
if now - _revoked_fetched_at < _REVOCATIONS_TTL:
return
_revoked_fetched_at = now
settings = get_settings()
try:
import httpx
headers = {}
if settings.sso_machine_key:
headers["X-Draco-Machine-Key"] = settings.sso_machine_key
r = httpx.get(f"{settings.sso_url}/api/sso/revocations",
headers=headers, timeout=2.0)
if r.status_code == 200:
_revoked_cache.clear()
for item in r.json().get("revoked", []):
_revoked_cache[str(item["jti"])] = int(item["exp"])
except Exception as exc: # l'émettrice injoignable ne bloque pas le SSO
logger.debug("Révocations draco indisponibles: %s", exc)
def is_revoked(jti: str) -> bool:
_refresh_revocations()
return jti in _revoked_cache
def claims_for(request) -> dict | None:
"""Claims valides du cookie draco de la requête (révocation incluse)."""
if not enabled():
return None
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
jti = str(claims.get("jti", ""))
if jti and is_revoked(jti):
return None
if not claims.get("sub"):
return None
return claims
+70
View File
@@ -0,0 +1,70 @@
"""Encryption at rest for forge tokens (0.24.0, P0/P2).
Forge access tokens are the keys to the kingdom: they are
stored Fernet-encrypted (AES-128-CBC + HMAC) in
forge_connections.token_encrypted and never shown again after
entry (the admin UI only ever displays a mask).
Master key resolution (decision D5):
1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended;
2. otherwise a random key generated ONCE and kept in kv_store
("master_key_generated"): as safe as the database file
itself, zero configuration, but it travels with DB backups —
set the env var for anything stricter.
"""
import logging
import os
import secrets as _secrets
from cryptography.fernet import Fernet
from app import models
logger = logging.getLogger("kanbante.crypto")
_fernet: Fernet | None = None
def _master_key() -> bytes:
env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip()
if env_key:
return env_key.encode("utf-8")
stored = models.get_kv("master_key_generated")
if stored:
return stored.encode("utf-8")
generated = Fernet.generate_key()
models.set_kv("master_key_generated", generated.decode("utf-8"))
logger.warning(
"KANBANTE_MASTER_KEY not set: generated a master key and "
"stored it in the database (kv_store). Set the env var to "
"control it explicitly."
)
return generated
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = Fernet(_master_key())
return _fernet
def encrypt_token(plain: str) -> str:
if not plain:
return ""
return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
def decrypt_token(stored: str) -> str:
if not stored:
return ""
try:
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
except Exception as exc:
logger.error("forge token decryption failed: %s", exc)
return ""
def mask_token(stored: str) -> str:
return "••••••••" if stored else "—"
+48 -8
View File
@@ -38,7 +38,18 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
class GiteaClient:
"""Lightweight async Gitea API client for Kanbante."""
"""Lightweight async Gitea API client for Kanbante.
Since 0.24.0 this class is the reference implementation of
the forge protocol: GitHubClient subclasses it and overrides
the endpoints whose shape differs. ``api_suffix`` and
``auth_scheme`` are the two seams; ``key_prefix`` namespaces
the persisted snapshots per connection ("c<id>:").
"""
kind = "gitea"
api_suffix = "/api/v1"
auth_scheme = "token"
def __init__(
self,
@@ -51,11 +62,15 @@ class GiteaClient:
self.base_url = (base_url or settings.gitea_url).rstrip("/")
self.token = settings.gitea_token if token is None else token
self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl
self.key_prefix = ""
self.connection_id = 1
self.connection_slug = "gitea"
headers = {"Accept": "application/json"}
headers.update(self._extra_headers())
if self.token:
headers["Authorization"] = f"token {self.token}"
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1",
base_url=f"{self.base_url}{self.api_suffix}",
headers=headers,
timeout=15.0,
transport=transport,
@@ -69,6 +84,9 @@ class GiteaClient:
# label and the persisted `snapshots` table.
self._snapshot_times: dict[str, str] = {}
def _extra_headers(self) -> dict:
return {}
async def aclose(self) -> None:
await self.client.aclose()
@@ -120,6 +138,9 @@ class GiteaClient:
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
def _prefixed(self, key: str) -> str:
return f"{self.key_prefix}{key}"
def _persist_snapshot(self, key: str, value: Any) -> None:
"""Write-through to the SQLite snapshots table.
@@ -129,7 +150,7 @@ class GiteaClient:
try:
from app import models
models.save_snapshot(key, value)
models.save_snapshot(self._prefixed(key), value)
import datetime
self._snapshot_times[key] = datetime.datetime.now(
@@ -157,12 +178,20 @@ class GiteaClient:
logger.warning("snapshot hydration failed: %s", exc)
return 0
stale_at = time.monotonic() - self.cache_ttl - 1.0
for key, (payload, updated_at) in snapshots.items():
count = 0
for stored_key, (payload, updated_at) in snapshots.items():
if self.key_prefix:
if not stored_key.startswith(self.key_prefix):
continue
key = stored_key[len(self.key_prefix):]
else:
key = stored_key
self._cache[key] = (stale_at, payload)
self._snapshot_times[key] = updated_at
if snapshots:
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
return len(snapshots)
count += 1
if count:
logger.info("hydrated %d snapshot(s) from SQLite", count)
return count
def latest_snapshot_at(self) -> str | None:
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
@@ -487,6 +516,17 @@ class GiteaClient:
self._cache_clear()
return r.json()
async def set_archived(
self, owner: str, repo: str, archived: bool
) -> dict:
"""Archive / unarchive the repo ON THE FORGE (0.24.0, P4)."""
r = await self.client.put(
f"/repos/{owner}/{repo}", json={"archived": archived}
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
+151
View File
@@ -0,0 +1,151 @@
"""GitHub forge client (0.24.0, P1).
Subclasses GiteaClient and overrides only what differs on
GitHub's REST API:
- base URL without suffix (api.github.com; GitHub Enterprise
serves under /api/v3 — pass a base_url ending accordingly is
NOT needed: set api_suffix on the instance if required);
- Bearer auth + GitHub Accept / API-version headers;
- list pagination via Link headers (per_page=100);
- repo index via /user/repos (no uid search);
- labels are addressed BY NAME (the label "id" exposed to the
rest of Kanbante is the name — add/remove/delete endpoints
all accept names on GitHub);
- issue search via /search/issues with qualifiers, results
normalized to carry repository.full_name like Gitea's;
- archiving via PATCH (Gitea uses PUT).
"""
import re
from typing import Any
from app.gitea_client import GiteaClient
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
class GitHubClient(GiteaClient):
kind = "github"
api_suffix = ""
auth_scheme = "Bearer"
def _extra_headers(self) -> dict:
return {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
# ── Pagination (Link headers) ──────────────────────────
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
items: list[dict] = []
url: str | None = path
query: dict[str, Any] = {**params, "per_page": 100}
pages = 0
while url and pages < 50:
pages += 1
if url.startswith("http"):
r = await self.client.get(url)
else:
r = await self.client.get(url, params=query)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "items" in data:
batch = data["items"]
elif isinstance(data, list):
batch = data
else:
batch = []
items.extend(batch)
match = _LINK_NEXT.search(r.headers.get("Link", ""))
url = match.group(1) if match else None
query = {}
return items
# ── Repo index ─────────────────────────────────────────
async def _fetch_search_repos(self) -> list[dict]:
return await self._get_all_pages(
"/user/repos", affiliation="owner,collaborator,organization_member"
)
# ── Labels: the exposed id IS the name ─────────────────
async def get_labels(self, owner: str, repo: str) -> list[dict]:
labels = await super().get_labels(owner, repo)
for label in labels:
label["id"] = label["name"]
return labels
async def create_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
label = await super().create_label(owner, repo, name, color)
label["id"] = label["name"]
return label
async def add_issue_label(
self, owner: str, repo: str, number: int, label_id
) -> None:
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/labels",
json={"labels": [str(label_id)]},
)
r.raise_for_status()
async def remove_issue_label(
self, owner: str, repo: str, number: int, label_id
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
)
r.raise_for_status()
# ── Issue search ───────────────────────────────────────
async def search_issues(
self,
q: str = "",
state: str = "open",
assigned_by: str | None = None,
) -> list[dict]:
qualifiers = [q.strip(), "is:issue", f"state:{state}"]
if assigned_by:
qualifiers.append(f"assignee:{assigned_by}")
query = " ".join(part for part in qualifiers if part)
raw = await self._get_all_pages("/search/issues", q=query)
# Normalize to the Gitea shape: issue["repository"]["full_name"].
for item in raw:
url = item.get("repository_url", "")
full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else ""
item["repository"] = {"full_name": full_name}
return raw
# ── Archiving (PATCH on GitHub) ────────────────────────
async def set_archived(
self, owner: str, repo: str, archived: bool
) -> dict:
r = await self.client.patch(
f"/repos/{owner}/{repo}", json={"archived": archived}
)
r.raise_for_status()
self._cache_clear()
return r.json()
# ── Branches (git/refs on GitHub) ──────────────────────
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
ref = await self._get(
f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}"
)
sha = (ref.get("object") or {}).get("sha", "")
r = await self.client.post(
f"/repos/{owner}/{repo}/git/refs",
json={"ref": f"refs/heads/{new_branch}", "sha": sha},
)
r.raise_for_status()
return r.json()
+2128 -218
View File
File diff suppressed because it is too large Load Diff
+687 -57
View File
@@ -49,25 +49,31 @@ def init_db() -> None:
"""
CREATE TABLE IF NOT EXISTS board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
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)
UNIQUE(workspace_id, connection_id, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
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)
UNIQUE(workspace_id, connection_id, repo_full_name)
);
CREATE TABLE IF NOT EXISTS board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
@@ -82,10 +88,12 @@ def init_db() -> None:
CREATE TABLE IF NOT EXISTS today_picks (
pick_date TEXT NOT NULL,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
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)
PRIMARY KEY (pick_date, workspace_id, connection_id, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
@@ -98,6 +106,79 @@ def init_db() -> None:
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Multi-forges, utilisateurs, espaces (0.24.0) ──
CREATE TABLE IF NOT EXISTS forge_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL DEFAULT 'gitea',
name TEXT NOT NULL,
base_url TEXT NOT NULL,
token_encrypted TEXT NOT NULL DEFAULT '',
account_login TEXT NOT NULL DEFAULT '',
webhook_secret TEXT NOT NULL DEFAULT '',
owner_user_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
display_name TEXT NOT NULL DEFAULT '',
draco_sub TEXT UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#7c5cfc',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workspace_connections (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
PRIMARY KEY (workspace_id, connection_id)
);
CREATE TABLE IF NOT EXISTS workspace_projects (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
repo_full_name TEXT NOT NULL,
PRIMARY KEY (workspace_id, connection_id, repo_full_name)
);
CREATE TABLE IF NOT EXISTS workspace_members (
workspace_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
PRIMARY KEY (workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS project_states (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
repo_full_name TEXT NOT NULL,
local_archived INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (workspace_id, connection_id, repo_full_name)
);
CREATE TABLE IF NOT EXISTS admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at);
"""
@@ -121,6 +202,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
"UPDATE project_notes SET updated_at = created_at "
"WHERE updated_at IS NULL"
)
_migrate_v24(conn)
# Rows written under a column name that no longer exists used to crash
# get_board_state() with a KeyError. Drop them once, here.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
@@ -130,6 +212,99 @@ def _migrate(conn: sqlite3.Connection) -> None:
)
def _migrate_v24(conn: sqlite3.Connection) -> None:
"""0.24.0: scope columns (workspace_id, connection_id).
board_state / project_notes / today_picks carry UNIQUE
constraints on repo_full_name alone — SQLite cannot alter a
constraint, so legacy tables are rebuilt with the scoped
uniqueness; existing rows land in scope (1, 1) — the legacy
connection and the Principal workspace.
"""
def _cols(table: str) -> list[str]:
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})")]
if _cols("board_state") and "connection_id" not in _cols("board_state"):
conn.executescript(
"""
CREATE TABLE board_state_v24 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
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(workspace_id, connection_id, repo_full_name, issue_number)
);
INSERT INTO board_state_v24
(id, workspace_id, connection_id, repo_full_name,
issue_number, column_name, position, updated_at)
SELECT id, 1, 1, repo_full_name, issue_number, column_name,
position, updated_at FROM board_state;
DROP TABLE board_state;
ALTER TABLE board_state_v24 RENAME TO board_state;
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
"""
)
if _cols("project_notes") and "connection_id" not in _cols("project_notes"):
conn.executescript(
"""
CREATE TABLE project_notes_v24 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
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(workspace_id, connection_id, repo_full_name)
);
INSERT INTO project_notes_v24
(id, workspace_id, connection_id, repo_full_name, note,
created_at, updated_at)
SELECT id, 1, 1, repo_full_name, note, created_at, updated_at
FROM project_notes;
DROP TABLE project_notes;
ALTER TABLE project_notes_v24 RENAME TO project_notes;
"""
)
if _cols("today_picks") and "connection_id" not in _cols("today_picks"):
conn.executescript(
"""
CREATE TABLE today_picks_v24 (
pick_date TEXT NOT NULL,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, workspace_id, connection_id,
repo_full_name, issue_number)
);
INSERT INTO today_picks_v24
(pick_date, workspace_id, connection_id, repo_full_name,
issue_number, created_at)
SELECT pick_date, 1, 1, repo_full_name, issue_number, created_at
FROM today_picks;
DROP TABLE today_picks;
ALTER TABLE today_picks_v24 RENAME TO today_picks;
"""
)
if _cols("board_events") and "connection_id" not in _cols("board_events"):
conn.execute(
"ALTER TABLE board_events ADD COLUMN workspace_id "
"INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"ALTER TABLE board_events ADD COLUMN connection_id "
"INTEGER NOT NULL DEFAULT 1"
)
def ping_db() -> bool:
"""Cheap check used by /api/readiness."""
try:
@@ -140,14 +315,17 @@ def ping_db() -> bool:
return False
def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
def get_board_state(
repo_full_name: str, workspace_id: int = 1, connection_id: int = 1
) -> dict[str, list[dict]]:
"""Return board columns with their issues, ordered by position."""
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, column_name, position, updated_at "
"FROM board_state WHERE repo_full_name = ? "
"FROM board_state WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? "
"ORDER BY position ASC, issue_number ASC",
(repo_full_name,),
(workspace_id, connection_id, repo_full_name),
).fetchall()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
@@ -166,30 +344,43 @@ def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
return columns
def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None:
def move_issue(
repo_full_name: str,
issue_number: int,
to_column: str,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Move an issue to a different column (appended at the end)."""
if to_column not in KANBAN_COLUMNS:
raise ValueError(f"Invalid column: {to_column}")
with get_db() as conn:
row = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state "
"WHERE repo_full_name = ? AND column_name = ?",
(repo_full_name, to_column),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND column_name = ?",
(workspace_id, connection_id, repo_full_name, to_column),
).fetchone()
conn.execute(
"""INSERT INTO board_state
(repo_full_name, issue_number, column_name, position, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(repo_full_name, issue_number)
(workspace_id, connection_id, repo_full_name, issue_number,
column_name, position, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name,
position = excluded.position,
updated_at = excluded.updated_at""",
(repo_full_name, issue_number, to_column, row["next_pos"]),
(workspace_id, connection_id, repo_full_name, issue_number,
to_column, row["next_pos"]),
)
def set_column_order(
repo_full_name: str, column_name: str, ordered_issue_numbers: list[int]
repo_full_name: str,
column_name: str,
ordered_issue_numbers: list[int],
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Persist the exact order of the cards in one column.
@@ -203,18 +394,24 @@ def set_column_order(
for index, issue_number in enumerate(ordered_issue_numbers):
conn.execute(
"""INSERT INTO board_state
(repo_full_name, issue_number, column_name, position, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(repo_full_name, issue_number)
(workspace_id, connection_id, repo_full_name,
issue_number, column_name, position, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name,
position = excluded.position,
updated_at = excluded.updated_at""",
(repo_full_name, issue_number, column_name, float(index)),
(workspace_id, connection_id, repo_full_name,
issue_number, column_name, float(index)),
)
def reconcile_board_state(
repo_full_name: str, open_numbers: set[int], closed_numbers: set[int]
repo_full_name: str,
open_numbers: set[int],
closed_numbers: set[int],
workspace_id: int = 1,
connection_id: int = 1,
) -> dict[str, int]:
"""Reconcile local board state with the truth in Gitea.
@@ -228,16 +425,18 @@ def reconcile_board_state(
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, column_name FROM board_state "
"WHERE repo_full_name = ?",
(repo_full_name,),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchall()
for row in rows:
number = row["issue_number"]
if number not in known:
conn.execute(
"DELETE FROM board_state "
"WHERE repo_full_name = ? AND issue_number = ?",
(repo_full_name, number),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?",
(workspace_id, connection_id, repo_full_name, number),
)
changed["purged"] += 1
changed["purged_numbers"].append(number)
@@ -245,40 +444,69 @@ def reconcile_board_state(
conn.execute(
"UPDATE board_state SET column_name = 'done', "
"updated_at = datetime('now') "
"WHERE repo_full_name = ? AND issue_number = ?",
(repo_full_name, number),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?",
(workspace_id, connection_id, repo_full_name, number),
)
changed["closed_to_done"] += 1
changed["closed_numbers"].append(number)
return changed
def get_picks(pick_date: str) -> list[dict]:
def get_picks(
pick_date: str, workspace_id: int = 1, connection_id: int | None = None
) -> list[dict]:
"""The day's focus picks (issue #5), in pinning order."""
with get_db() as conn:
rows = conn.execute(
"SELECT repo_full_name, issue_number FROM today_picks "
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number",
(pick_date,),
).fetchall()
if connection_id is None:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM today_picks WHERE pick_date = ? AND workspace_id = ? "
"ORDER BY created_at, repo_full_name, issue_number",
(pick_date, workspace_id),
).fetchall()
else:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM today_picks WHERE pick_date = ? AND workspace_id = ? "
"AND connection_id = ? "
"ORDER BY created_at, repo_full_name, issue_number",
(pick_date, workspace_id, connection_id),
).fetchall()
return [dict(row) for row in rows]
def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
def add_pick(
pick_date: str,
repo_full_name: str,
issue_number: int,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
with get_db() as conn:
conn.execute(
"INSERT OR IGNORE INTO today_picks "
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)",
(pick_date, repo_full_name, issue_number),
"(pick_date, workspace_id, connection_id, repo_full_name, "
"issue_number) VALUES (?, ?, ?, ?, ?)",
(pick_date, workspace_id, connection_id, repo_full_name,
issue_number),
)
def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
def remove_pick(
pick_date: str,
repo_full_name: str,
issue_number: int,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM today_picks WHERE pick_date = ? "
"AND workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?",
(pick_date, repo_full_name, issue_number),
(pick_date, workspace_id, connection_id, repo_full_name,
issue_number),
)
@@ -354,6 +582,8 @@ def log_event(
to_column: str | None = None,
source: str = "ui",
created_at: str | None = None,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Append one entry to the board's event journal.
@@ -365,19 +595,21 @@ def log_event(
with get_db() as conn:
if created_at:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source, created_at),
"INSERT INTO board_events (workspace_id, connection_id, "
"repo_full_name, issue_number, event_type, from_column, "
"to_column, source, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(workspace_id, connection_id, repo_full_name, issue_number,
event_type, from_column, to_column, source, created_at),
)
else:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source) "
"VALUES (?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source),
"INSERT INTO board_events (workspace_id, connection_id, "
"repo_full_name, issue_number, event_type, from_column, "
"to_column, source) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(workspace_id, connection_id, repo_full_name, issue_number,
event_type, from_column, to_column, source),
)
@@ -400,7 +632,9 @@ def get_events(
return [dict(row) for row in rows]
def column_entry_times(repo_full_name: str) -> dict[int, str]:
def column_entry_times(
repo_full_name: str, workspace_id: int = 1, connection_id: int = 1
) -> dict[int, str]:
"""When each issue entered its current column, per the journal.
Latest event per issue, comments excluded (a comment does not move
@@ -410,9 +644,10 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, MAX(created_at) AS entered_at "
"FROM board_events WHERE repo_full_name = ? "
"FROM board_events WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? "
"AND event_type != 'commented' GROUP BY issue_number",
(repo_full_name,),
(workspace_id, connection_id, repo_full_name),
).fetchall()
return {row["issue_number"]: row["entered_at"] for row in rows}
@@ -506,23 +741,32 @@ def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dic
}
def set_notes(repo_full_name: str, note: str) -> None:
def set_notes(
repo_full_name: str,
note: str,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO project_notes (repo_full_name, note)
VALUES (?, ?)
ON CONFLICT(repo_full_name)
"""INSERT INTO project_notes
(workspace_id, connection_id, repo_full_name, note)
VALUES (?, ?, ?, ?)
ON CONFLICT(workspace_id, connection_id, repo_full_name)
DO UPDATE SET note = excluded.note,
updated_at = datetime('now')""",
(repo_full_name, note),
(workspace_id, connection_id, repo_full_name, note),
)
def get_notes(repo_full_name: str) -> str | None:
def get_notes(
repo_full_name: str, workspace_id: int = 1, connection_id: int = 1
) -> str | None:
with get_db() as conn:
row = conn.execute(
"SELECT note FROM project_notes WHERE repo_full_name = ?",
(repo_full_name,),
"SELECT note FROM project_notes WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchone()
return row["note"] if row else None
@@ -544,3 +788,389 @@ def get_stats() -> dict:
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
projects[name][row["column_name"]] = row["cnt"]
return projects
# ── Connexions aux forges (0.24.0, P0) ────────────────────
def list_connections(include_disabled: bool = True) -> list[dict]:
query = "SELECT * FROM forge_connections"
if not include_disabled:
query += " WHERE enabled = 1"
query += " ORDER BY id"
with get_db() as conn:
return [dict(r) for r in conn.execute(query).fetchall()]
def get_connection(connection_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM forge_connections WHERE id = ?", (connection_id,)
).fetchone()
return dict(row) if row else None
def get_connection_by_slug(slug: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM forge_connections WHERE slug = ?", (slug,)
).fetchone()
return dict(row) if row else None
def create_connection(
slug: str,
kind: str,
name: str,
base_url: str,
token_encrypted: str = "",
account_login: str = "",
webhook_secret: str = "",
owner_user_id: int | None = None,
) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO forge_connections (slug, kind, name, base_url, "
"token_encrypted, account_login, webhook_secret, owner_user_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(slug, kind, name, base_url, token_encrypted, account_login,
webhook_secret, owner_user_id),
)
return int(cur.lastrowid)
def update_connection(connection_id: int, **fields) -> None:
allowed = {
"slug", "kind", "name", "base_url", "token_encrypted",
"account_login", "webhook_secret", "enabled", "owner_user_id",
}
items = {k: v for k, v in fields.items() if k in allowed}
if not items:
return
assignments = ", ".join(f"{k} = ?" for k in items)
with get_db() as conn:
conn.execute(
f"UPDATE forge_connections SET {assignments} WHERE id = ?",
(*items.values(), connection_id),
)
def delete_connection(connection_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM forge_connections WHERE id = ?", (connection_id,)
)
conn.execute(
"DELETE FROM workspace_connections WHERE connection_id = ?",
(connection_id,),
)
conn.execute(
"DELETE FROM workspace_projects WHERE connection_id = ?",
(connection_id,),
)
# ── Utilisateurs (0.24.0, P2) ──────────────────────────────
def count_users() -> int:
with get_db() as conn:
return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0])
def list_users() -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall()
]
def get_user(user_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
return dict(row) if row else None
def get_user_by_username(username: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
return dict(row) if row else None
def get_user_by_draco(sub: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE draco_sub = ?", (sub,)
).fetchone()
return dict(row) if row else None
def create_user(
username: str,
password_hash: str,
role: str = "member",
display_name: str = "",
must_change_password: int = 0,
) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO users (username, password_hash, role, display_name, "
"must_change_password) VALUES (?, ?, ?, ?, ?)",
(username, password_hash, role, display_name, must_change_password),
)
return int(cur.lastrowid)
def update_user(user_id: int, **fields) -> None:
allowed = {
"password_hash", "role", "display_name", "draco_sub", "active",
"must_change_password", "last_login_at",
}
items = {k: v for k, v in fields.items() if k in allowed}
if not items:
return
assignments = ", ".join(f"{k} = ?" for k in items)
with get_db() as conn:
conn.execute(
f"UPDATE users SET {assignments} WHERE id = ?",
(*items.values(), user_id),
)
# ── Espaces de travail (0.24.0, P3) ────────────────────────
def list_workspaces() -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute(
"SELECT * FROM workspaces ORDER BY id"
).fetchall()
]
def get_workspace(workspace_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM workspaces WHERE id = ?", (workspace_id,)
).fetchone()
return dict(row) if row else None
def create_workspace(slug: str, name: str, color: str = "#7c5cfc") -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO workspaces (slug, name, color) VALUES (?, ?, ?)",
(slug, name, color),
)
return int(cur.lastrowid)
def update_workspace(workspace_id: int, **fields) -> None:
allowed = {"slug", "name", "color"}
items = {k: v for k, v in fields.items() if k in allowed}
if not items:
return
assignments = ", ".join(f"{k} = ?" for k in items)
with get_db() as conn:
conn.execute(
f"UPDATE workspaces SET {assignments} WHERE id = ?",
(*items.values(), workspace_id),
)
def delete_workspace(workspace_id: int) -> None:
with get_db() as conn:
for table in (
"workspace_connections", "workspace_projects",
"workspace_members", "project_states",
):
conn.execute(
f"DELETE FROM {table} WHERE workspace_id = ?", (workspace_id,)
)
conn.execute("DELETE FROM workspaces WHERE id = ?", (workspace_id,))
def workspace_connection_ids(workspace_id: int) -> list[int]:
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id FROM workspace_connections "
"WHERE workspace_id = ? ORDER BY connection_id",
(workspace_id,),
).fetchall()
return [r[0] for r in rows]
def set_workspace_connections(workspace_id: int, connection_ids: list[int]) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM workspace_connections WHERE workspace_id = ?",
(workspace_id,),
)
conn.executemany(
"INSERT OR IGNORE INTO workspace_connections "
"(workspace_id, connection_id) VALUES (?, ?)",
[(workspace_id, cid) for cid in connection_ids],
)
def workspace_project_selection(workspace_id: int) -> list[tuple[int, str]]:
"""Explicit project inclusions; empty means "all projects"."""
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id, repo_full_name FROM workspace_projects "
"WHERE workspace_id = ? ORDER BY connection_id, repo_full_name",
(workspace_id,),
).fetchall()
return [(r[0], r[1]) for r in rows]
def set_workspace_projects(
workspace_id: int, selection: list[tuple[int, str]]
) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM workspace_projects WHERE workspace_id = ?",
(workspace_id,),
)
conn.executemany(
"INSERT OR IGNORE INTO workspace_projects "
"(workspace_id, connection_id, repo_full_name) VALUES (?, ?, ?)",
[(workspace_id, cid, repo) for cid, repo in selection],
)
def list_members(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT m.user_id, m.role, u.username, u.display_name "
"FROM workspace_members m JOIN users u ON u.id = m.user_id "
"WHERE m.workspace_id = ? ORDER BY u.username",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def set_member(workspace_id: int, user_id: int, role: str) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) "
"VALUES (?, ?, ?) ON CONFLICT(workspace_id, user_id) "
"DO UPDATE SET role = excluded.role",
(workspace_id, user_id, role),
)
def remove_member(workspace_id: int, user_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM workspace_members WHERE workspace_id = ? "
"AND user_id = ?",
(workspace_id, user_id),
)
def user_workspaces(user_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT w.*, m.role AS member_role FROM workspaces w "
"JOIN workspace_members m ON m.workspace_id = w.id "
"WHERE m.user_id = ? ORDER BY w.id",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def workspace_role(workspace_id: int, user_id: int) -> str | None:
with get_db() as conn:
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id = ? "
"AND user_id = ?",
(workspace_id, user_id),
).fetchone()
return row[0] if row else None
# ── États de projets / archives locales (0.24.0, P4) ──────
def get_project_state(
workspace_id: int, connection_id: int, repo_full_name: str
) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM project_states WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchone()
return dict(row) if row else None
def set_local_archived(
workspace_id: int,
connection_id: int,
repo_full_name: str,
archived: bool,
) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO project_states (workspace_id, connection_id, "
"repo_full_name, local_archived, updated_at) "
"VALUES (?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(workspace_id, connection_id, repo_full_name) "
"DO UPDATE SET local_archived = excluded.local_archived, "
"updated_at = excluded.updated_at",
(workspace_id, connection_id, repo_full_name, 1 if archived else 0),
)
def list_local_archived(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM project_states WHERE workspace_id = ? "
"AND local_archived = 1 ORDER BY repo_full_name",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def purge_project_local_state(
workspace_id: int, connection_id: int, repo_full_name: str
) -> None:
"""Drop every local trace of a project in one workspace."""
with get_db() as conn:
for table in ("board_state", "project_notes", "today_picks"):
conn.execute(
f"DELETE FROM {table} WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
)
conn.execute(
"DELETE FROM project_states WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
)
# ── Journal d'administration (0.24.0, P2) ─────────────────
def log_admin(user_id: int | None, action: str, details: str = "") -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO admin_audit (user_id, action, details) "
"VALUES (?, ?, ?)",
(user_id, action, details),
)
def list_admin_audit(limit: int = 100) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT a.*, u.username FROM admin_audit a "
"LEFT JOIN users u ON u.id = a.user_id "
"ORDER BY a.id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
+15 -10
View File
@@ -67,22 +67,27 @@ async def refresh_snapshots(gitea, classify_repos) -> int:
return len(active)
async def snapshot_loop(gitea, classify_repos) -> None:
async def snapshot_loop(clients_provider, classify_repos) -> None:
"""Periodic refresher, started by the app lifespan.
Sleeps briefly first: hydration already serves the pages,
the loop only has to keep the snapshots warm.
the loop only has to keep the snapshots warm. Since 0.24.0
`clients_provider` is a zero-arg callable returning the live
forge clients (one per global connection); each is refreshed
in turn. The interval is re-read every cycle so the admin
settings panel can change it without a restart.
"""
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
await asyncio.sleep(10)
while True:
try:
await refresh_snapshots(gitea, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
for client in clients_provider():
try:
await refresh_snapshots(client, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
await asyncio.sleep(interval)
+98
View File
@@ -0,0 +1,98 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
<script>document.body.classList.add('has-draco-bar');</script>
<header class="draco-bar" data-current="kanbante">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d&#39;application" title="Changer d&#39;application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" title="Opérateur Kanbante">{{ draco_avatar }}</span>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script>
/* Configuration Kanbante de la barre commune (Fusion ACC-1).
Phase 1 : navigation entre apps et domaine mémorisé localement ;
le rail pilote le repli natif de la barre latérale, la palette
ouvre la recherche & commandes native (Ctrl+K) ; les alertes
unifiées, la recherche fédérée et Spark arrivent avec les
phases suivantes du programme Fusion. */
window.DRACO_BAR_CONFIG = {
current: "kanbante",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
var dire = function (m) {
var d = document.createElement("div");
d.textContent = m;
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:3000;background:#1f1f1f;color:#eee;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
document.body.appendChild(d);
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; }
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
el.hidden = true;
}
}
};
</script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
+27 -1
View File
@@ -1,3 +1,4 @@
{% include "_draco_bar.html" %}
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
@@ -27,9 +28,20 @@
("outils", "Outils", [
("search", "/search", "🔍", "Recherche"),
("capture", "/capture", "⚡", "Capture"),
("archives", "/archives", "🗄️", "Archives"),
("wall", "/wall", "🖥️", "Wallboard"),
]),
] %}
{% if is_admin %}
{% set nav_groups = nav_groups + [
("admin", "Administration", [
("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]),
] %}
{% endif %}
{% set active_group = namespace(key="principal") %}
{% for gkey, group, links in nav_groups %}
{% for key, href, icon, label in links %}
@@ -48,7 +60,7 @@
localStorage.removeItem('kanbante-nav-closed');
var openGroup = {{ active_group.key | tojson }};
var stored = localStorage.getItem('kanbante-nav-open');
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin'];
if ({{ (active_nav != "") | tojson }}) {
localStorage.setItem('kanbante-nav-open', openGroup);
} else if (stored === '') {
@@ -70,6 +82,17 @@
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
</div>
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
<form method="post" action="/workspace/switch" class="ws-switch">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<span class="ws-dot" style="background: {{ current_workspace.color }}" aria-hidden="true"></span>
<select name="workspace_id" class="ws-select" onchange="this.form.submit()" title="Changer d'espace de travail" aria-label="Espace de travail">
{% for ws in user_workspaces %}
<option value="{{ ws.id }}"{% if ws.id == current_workspace.id %} selected{% endif %}>{{ ws.name }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<nav class="side-nav">
{% for gkey, group, links in nav_groups %}
<div class="side-section" data-group="{{ gkey }}">
@@ -94,6 +117,9 @@
{% if data_freshness %}
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
{% endif %}
{% if current_user %}
<a href="/profile" class="side-logout" title="Mon profil">👤<span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
{% endif %}
<span class="side-version side-label">v{{ version }}</span>
{% if auth_enabled %}
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🛠️ Administration</h1>
<p class="page-sub">Utilisateurs, vue d'ensemble et journal d'administration.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>👥 Utilisateurs ({{ users|length }})</h2>
<table class="proj-table">
<thead>
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong>{% if u.display_name %} <span class="muted">({{ u.display_name }})</span>{% endif %}{% if u.must_change_password %} <span class="chip-warn">mot de passe à changer</span>{% endif %}</td>
<td>
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="role" onchange="this.form.submit()" {% if current_user.id == u.id %}disabled{% endif %}>
<option value="member"{% if u.role == 'member' %} selected{% endif %}>Membre</option>
<option value="admin"{% if u.role == 'admin' %} selected{% endif %}>Admin</option>
</select>
</form>
</td>
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</td>
<td>{{ u.last_login_at or '—' }}</td>
<td class="admin-actions">
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini" {% if current_user.id == u.id %}disabled title="Impossible sur soi-même"{% endif %}>{% if u.active %}Désactiver{% else %}Activer{% endif %}</button>
</form>
<form method="post" action="/admin/users/{{ u.id }}/reset" class="inline-form" onsubmit="var p=this.querySelector('input[name=password]'); if(!p.value){p.focus(); return false;}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="password" placeholder="Nouveau mot de passe" autocomplete="new-password">
<button type="submit" class="btn-mini">Réinitialiser</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>➕ Nouvel utilisateur</h3>
<form method="post" action="/admin/users" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom d'utilisateur <input type="text" name="username" required autocomplete="off"></label>
<label>Nom d'affichage <input type="text" name="display_name" autocomplete="off"></label>
<label>Mot de passe temporaire <input type="password" name="password" required autocomplete="new-password"></label>
<label>Rôle
<select name="role">
<option value="member">Membre</option>
<option value="admin">Admin</option>
</select>
</label>
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
<p class="notes-hint">L'utilisateur rejoint automatiquement l'espace Principal comme membre et devra changer son mot de passe temporaire (rappel affiché ici et dans son profil).</p>
</section>
<section class="briefing-card">
<h2>🔭 Vue d'ensemble</h2>
<p>
<a href="/admin/connections">🔌 Connexions ({{ connections|length }})</a> ·
<a href="/admin/workspaces">🧭 Espaces ({{ workspaces|length }})</a> ·
<a href="/admin/settings">🎛️ Paramètres</a> ·
<a href="/archives">🗄️ Archives</a>
</p>
</section>
<section class="briefing-card">
<h2>📜 Journal d'administration</h2>
{% if audit %}
<table class="proj-table">
<thead><tr><th>Quand</th><th>Qui</th><th>Action</th><th>Détails</th></tr></thead>
<tbody>
{% for a in audit %}
<tr><td>{{ a.created_at }}</td><td>{{ a.username or '—' }}</td><td><code>{{ a.action }}</code></td><td>{{ a.details }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucune action d'administration journalisée pour l'instant.</p>
{% endif %}
</section>
</main>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Connexions — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🔌 Connexions aux forges</h1>
<p class="page-sub">Chaque connexion est un accès Gitea ou GitHub. Les jetons sont <strong>chiffrés au repos</strong> et ne sont <strong>jamais réaffichés</strong> — pour en changer un, saisis la nouvelle valeur.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% for c in connections %}
<section class="briefing-card">
<h2>{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }}
<span class="muted">({{ c.slug }})</span>
{% if c.owner_user_id %}<span class="chip-warn">personnelle</span>{% endif %}
{% if not c.enabled %}<span class="chip-warn">désactivée</span>{% endif %}
</h2>
<p class="muted">
{{ c.kind }} · {{ c.base_url }} · compte : <strong>{{ c.account_login or 'à tester' }}</strong> · jeton : {{ c.token_mask }}<br>
Webhook : <code>/webhooks/{{ c.slug }}</code> (secret {% if c.webhook_secret %}défini{% else %}hérité ou absent{% endif %})
{% if c.used_by %} · Utilisée par : {{ c.used_by|join(', ') }}{% endif %}
</p>
<form method="post" action="/admin/connections/{{ c.id }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/connections/{{ c.id }}/test" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">🧪 Tester la connexion</button>
</form>
<form method="post" action="/admin/connections/{{ c.id }}/toggle" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">{% if c.enabled %}Désactiver{% else %}Activer{% endif %}</button>
</form>
<form method="post" action="/admin/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer définitivement cette connexion ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
</form>
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle connexion</h2>
<form method="post" action="/admin/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
<select name="kind">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
<button type="submit" class="btn-save btn-inline">Ajouter</button>
</form>
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
</section>
</main>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Paramètres — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🎛️ Paramètres</h1>
<p class="page-sub"><strong>L'environnement amorce, le panneau gouverne.</strong> Une valeur saisie ici remplace celle de l'environnement (sans redémarrage pour la plupart) ; un champ vidé revient à la valeur d'environnement. Les secrets et les réglages d'amorçage (base de données, clé maître, session, draco) restent en environnement, volontairement.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<form method="post" action="/admin/settings">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="proj-table">
<thead><tr><th>Réglage</th><th>Environnement</th><th>Valeur du panneau (vide = env)</th><th>Effectif</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td><code>{{ r.key }}</code></td>
<td class="muted">{{ r.env_value }}</td>
<td><input type="text" name="setting_{{ r.key }}" value="{{ r.override if r.override is not none else '' }}" placeholder="{{ r.env_value }}"></td>
<td><strong>{{ r.effective }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="submit" class="btn-save btn-inline">Enregistrer</button></p>
</form>
<p class="notes-hint">Booléens : <code>true</code>/<code>false</code>. <code>cache_ttl</code> s'applique aux connexions construites après l'enregistrement ; l'intervalle des instantanés est relu à chaque cycle. Les horaires (<code>briefing_time</code>, <code>digest_time</code>) sont au format <code>HH:MM</code>, <code>digest_weekday</code> : 0 = lundi.</p>
</section>
</main>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Espaces — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧭 Espaces de travail</h1>
<p class="page-sub">Un espace regroupe des connexions (forges) et des membres. Le board d'un projet appartient à l'espace : deux espaces peuvent ranger le même projet différemment. L'état de board, les notes et les épingles du jour sont propres à chaque espace.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% for d in details %}
{% set ws = d.ws %}
<section class="briefing-card">
<h2><span class="ws-dot" style="background: {{ ws.color }}"></span> {{ ws.name }} <span class="muted">({{ ws.slug }})</span></h2>
<form method="post" action="/admin/workspaces/{{ ws.id }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" value="{{ ws.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ ws.color }}"></label>
<button type="submit" class="btn-save btn-inline">Renommer</button>
</form>
<h3>🔌 Connexions de l'espace</h3>
<form method="post" action="/admin/workspaces/{{ ws.id }}/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for c in all_connections %}
<label>
<input type="checkbox" name="connection_id" value="{{ c.id }}"{% if c.id in d.conn_ids %} checked{% endif %}>
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
</label>
{% endfor %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<h3>👥 Membres</h3>
{% if d.members %}
<table class="proj-table">
<thead><tr><th>Utilisateur</th><th>Rôle dans l'espace</th><th></th></tr></thead>
<tbody>
{% for m in d.members %}
<tr>
<td>{{ m.username }}{% if m.display_name %} <span class="muted">({{ m.display_name }})</span>{% endif %}</td>
<td>{{ m.role }}</td>
<td>
<form method="post" action="/admin/workspaces/{{ ws.id }}/members/{{ m.user_id }}/remove" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Retirer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucun membre explicite (les admins globaux voient tout).</p>
{% endif %}
<form method="post" action="/admin/workspaces/{{ ws.id }}/members" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Utilisateur
<select name="user_id">
{% for u in all_users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
</label>
<label>Rôle
<select name="role">
<option value="reader">Lecteur</option>
<option value="member" selected>Membre</option>
<option value="admin">Admin d'espace</option>
</select>
</label>
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
</form>
<h3>📦 Sélection de projets</h3>
<p class="notes-hint">Vide = <strong>tous</strong> les projets des connexions de l'espace. Sinon, une ligne par projet au format <code>slug-connexion:proprietaire/depot</code> (ex. <code>github:bruno/mon-projet</code>).</p>
<form method="post" action="/admin/workspaces/{{ ws.id }}/projects">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="selection" rows="4" style="width:100%">{% for cid, full in d.selection %}{% for c in all_connections %}{% if c.id == cid %}{{ c.slug }}:{{ full }}
{% endif %}{% endfor %}{% endfor %}</textarea>
<p><button type="submit" class="btn-save btn-inline">Enregistrer la sélection</button></p>
</form>
{% if is_global_admin %}
<form method="post" action="/admin/workspaces/{{ ws.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cet espace ? Les boards propres à cet espace seront perdus.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer l'espace</button>
</form>
{% endif %}
</section>
{% endfor %}
{% if is_global_admin %}
<section class="briefing-card">
<h2>➕ Nouvel espace</h2>
<form method="post" action="/admin/workspaces" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" required placeholder="Open source"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
</section>
{% endif %}
</main>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Archives — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🗄️ Archives</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name if workspace else '—' }}</strong>. Deux sortes d'archives : <strong>locale</strong> (le projet disparaît des vues de cet espace, la forge n'est pas touchée, réversible en un clic) et <strong>sur la forge</strong> (propriété du dépôt, lecture seule là-bas). Les archives sortent des rituels et des instantanés, mais restent cherchables.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% macro actions(row, kind) %}
{% if can_write %}
{% if kind == 'suggested' or kind == 'forge' or kind == 'other' %}
<form method="post" action="/archives/local" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="1">
<button type="submit" class="btn-mini">📥 Archiver ici</button>
</form>
{% endif %}
{% if kind == 'local' %}
<form method="post" action="/archives/local" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="0">
<button type="submit" class="btn-mini">♻️ Restaurer</button>
</form>
<form method="post" action="/archives/purge" class="inline-form" onsubmit="return confirm('Purger l\'état local (board, notes, épingles) de ce projet dans cet espace ? La forge n\'est pas touchée.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<button type="submit" class="btn-mini btn-danger">Purger l'état local</button>
</form>
{% endif %}
{% if kind != 'forge' and not row.missing %}
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Archiver ce projet SUR LA FORGE ? Il y devient en lecture seule.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="1">
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
</form>
{% endif %}
{% if kind == 'forge' %}
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Désarchiver ce projet sur la forge ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="0">
<button type="submit" class="btn-mini">🔓 Désarchiver sur la forge</button>
</form>
{% endif %}
<form method="post" action="/archives/export" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
</form>
{% endif %}
{% endmacro %}
{% macro rowline(row, kind) %}
<tr>
<td>
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
<strong>{{ row.full_name }}</strong>
{% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}
{% if row.missing %}<span class="chip-warn">introuvable sur la forge</span>{% endif %}
</td>
<td>{{ row.updated_at or '—' }}{% if row.idle_days is not none %} <span class="muted">(il y a {{ row.idle_days }} j)</span>{% endif %}</td>
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
<td class="admin-actions">{{ actions(row, kind) }}</td>
</tr>
{% endmacro %}
<section class="briefing-card">
<h2>💤 À archiver ? (inactifs depuis {{ suggest_days }} jours et plus)</h2>
{% if groups.suggested %}
<form method="post" action="/archives/local-bulk">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="proj-table">
<thead><tr><th></th><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>
{% for row in groups.suggested %}
<tr>
<td><input type="checkbox" name="item" value="{{ row.conn_id }}:{{ row.full_name }}" checked></td>
<td>{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</td>
<td>{{ row.updated_at }} <span class="muted">(il y a {{ row.idle_days }} j)</span></td>
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
<td class="admin-actions">{{ actions(row, 'suggested') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="submit" class="btn-save btn-inline">📥 Archiver localement la sélection</button></p>
</form>
{% else %}
<p class="muted">Rien à proposer : aucun projet inactif au-delà du seuil (réglable : <code>archive_suggest_days</code> dans Administration → Paramètres).</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
{% if groups.local %}
<table class="proj-table">
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>{% for row in groups.local %}{{ rowline(row, 'local') }}{% endfor %}</tbody>
</table>
{% else %}
<p class="muted">Aucune archive locale. « Archiver ici » masque un projet de cet espace sans toucher à la forge.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
{% if groups.forge %}
<table class="proj-table">
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>{% for row in groups.forge %}{{ rowline(row, 'forge') }}{% endfor %}</tbody>
</table>
{% else %}
<p class="muted">Aucun dépôt archivé sur les forges de cet espace.</p>
{% endif %}
</section>
</main>
</body>
</html>
+2
View File
@@ -55,6 +55,7 @@
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
<a href="/archives" class="seg-link" title="Gérer les archives (locales et forge)">🗄️ Gérer</a>
</div>
<div class="search-bar toolbar-search">
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
@@ -89,6 +90,7 @@
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if multi_conn %}<span class="conn-badge" title="Connexion {{ repo._conn_slug }}">{% if repo._conn_kind == 'github' %}🐙{% else %}🍵{% endif %} {{ repo._conn_slug }}</span>{% endif %}
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
+1
View File
@@ -29,6 +29,7 @@
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<a class="login-sso" href="https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fkanbante.dracodev.net%2Fauth%2Fdraco">🧭 Se connecter avec ObsiGate (SSO)</a>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section>
</main>
+95
View File
@@ -0,0 +1,95 @@
<!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>✦ Liaison draco (SSO)</h2>
{% if profile_user.draco_sub %}
<p>Compte lié à l'identité draco <code>{{ profile_user.draco_sub }}</code> — la connexion via ObsiGate ouvre directement cette session.</p>
<form method="post" action="/profile/draco/unlink" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Délier</button>
</form>
{% else %}
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🔌 Mes connexions personnelles</h2>
<p class="muted">Visibles de toi seul. Pour les utiliser, ajoute-les à un de tes espaces (ou demande à un admin d'espace).</p>
{% for c in my_connections %}
<p>
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ c.name }}</strong>
<span class="muted">({{ c.slug }}) · {{ c.base_url }} · compte : {{ c.account_login or 'à tester' }} · jeton : {{ c.token_mask }}</span>
<form method="post" action="/profile/connections/{{ c.id }}/test" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">🧪 Tester</button>
</form>
<form method="post" action="/profile/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette connexion personnelle ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
</form>
</p>
{% endfor %}
<form method="post" action="/profile/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
<select name="kind">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Nom <input type="text" name="name" required></label>
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com"></label>
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
<button type="submit" class="btn-save btn-inline">Ajouter</button>
</form>
</section>
</main>
</body>
</html>
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.22.0"
version = "0.24.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28",
"python-dotenv>=1.0",
"python-multipart>=0.0.18",
"cryptography>=42",
]
[tool.setuptools.packages.find]
+166
View File
@@ -0,0 +1,166 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
color: var(--ink); text-decoration: none;
}
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
.draco-apptab {
position: relative; display: flex; align-items: center; gap: 8px;
height: 44px; padding: 0 9px; border-radius: 12px;
color: var(--ink); text-decoration: none; background: transparent;
transition: background .18s ease;
}
.draco-apptab:hover { background: var(--surface-2); }
.draco-apptab-ico {
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
.draco-badge {
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
display: grid; place-items: center; border: 2px solid var(--surface);
}
.draco-badge[hidden] { display: none; }
/* Recherche */
.draco-search {
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
}
.draco-search kbd {
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
/* Domaines — contrôle segmenté */
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
.draco-domaines button {
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
}
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
.draco-bar-rappel {
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
}
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
@media (max-width: 1440px) {
.draco-brand-name, .draco-domaines-lbl { display: none; }
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
restaient affichés et la barre débordait sur téléphone.
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
barre complète ne tenait pas (fenêtres réduites, tablettes,
webviews) alors que les apps sont déjà en mise en page mobile.) */
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
.draco-apptab { display: none; }
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
}
+56 -3
View File
@@ -1210,16 +1210,22 @@ footer {
}
/* ── Barre latérale (0.16.0, #16 refonte) ── */
body.with-sidebar { padding-left: 238px; transition: padding-left 0.18s ease; }
/* Fluidité (0.23.1) : durée et courbe UNIFIÉES entre le padding du
corps et la largeur de la barre (avant : 0.18 s ease des deux
côtés mais transform à 0.22 s — mouvements désynchronisés), et
retrait du backdrop-filter : le fond est déjà opaque à 96 %,
le flou forçait un re-rendu coûteux à chaque image de l'animation
de largeur, d'où l'effet « lent et saccadé » signalé par Bruno. */
body.with-sidebar { padding-left: 238px; transition: padding-left 0.26s cubic-bezier(0.4, 0, 0.2, 1); }
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
width: 238px; box-sizing: border-box;
display: flex; flex-direction: column; gap: 6px;
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
background: rgba(13, 16, 24, 0.96); backdrop-filter: blur(14px);
background: rgba(13, 16, 24, 0.96);
border-right: 1px solid var(--border);
transition: width 0.18s ease, transform 0.22s ease;
transition: width 0.26s cubic-bezier(0.4, 0, 0.2, 1), transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
}
body.sidebar-collapsed .sidebar { width: 68px; }
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
@@ -1607,4 +1613,51 @@ a.ms-title:hover { text-decoration: underline; }
}
.login-submit:hover { filter: brightness(1.12); }
.login-submit:active { transform: scale(0.98); }
.login-sso {
display: block; margin-top: 12px; padding: 11px 18px; text-align: center;
color: var(--text-1, #e6ebf2); text-decoration: none;
border: 1px solid var(--border); border-radius: 10px;
font: inherit; font-weight: 500; font-size: 0.95rem;
transition: filter 0.15s, transform 0.1s;
}
.login-sso:hover { filter: brightness(1.35); }
.login-sso:active { transform: scale(0.98); }
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
/* ── Fusion ACC-1 (kanbante#28) : cohabitation avec la barre commune ──
La barre d'apps est FIXE et pleine largeur (0.23.1 — en flux, le
padding-left du cadre la repoussait à droite de la barre latérale,
constat de Bruno sur capture) ; le cadre entier est décalé de sa
hauteur, et la barre latérale fixe démarre sous elle. */
body.has-draco-bar { padding-top: 60px; }
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
body.has-draco-bar .sidebar { top: 60px; }
@media (max-width: 900px) {
body.has-draco-bar { padding-top: 52px; }
body.has-draco-bar .sidebar { top: 52px; }
}
/* ── Espaces, admin, profils (0.24.0) ─────────────────── */
.ws-switch { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; }
.ws-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #1c1c22); color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 8px; font: inherit; }
body.sidebar-collapsed .ws-switch { display: none; }
.admin-form { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; margin: 10px 0; }
.admin-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85em; }
.admin-form input[type="text"], .admin-form input[type="password"], .admin-form select {
background: var(--surface-2, #1c1c22); color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 7px 9px; font: inherit; }
.inline-form { display: inline-flex; gap: 6px; align-items: center; }
.btn-mini { padding: 4px 10px; font-size: 0.85em; border-radius: 8px;
border: 1px solid var(--border, #333); background: var(--surface-2, #1c1c22);
color: inherit; cursor: pointer; }
.btn-mini:hover { border-color: var(--accent, #7c5cfc); }
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-danger:hover { border-color: #e5534b; color: #e5534b; }
.chip-ok { display: inline-block; padding: 4px 10px; border-radius: 999px;
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.5); }
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border, #333); white-space: nowrap; }
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
+128
View File
@@ -0,0 +1,128 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
if (ico) a.appendChild(ico.cloneNode(true));
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var fort = t.querySelector(".draco-apptab-txt strong");
var petit = t.querySelector(".draco-apptab-txt small");
var nom = document.createElement("span");
nom.className = "draco-apps-item-nom";
nom.textContent = fort ? fort.textContent : t.dataset.app;
txt.appendChild(nom);
if (petit) {
var role = document.createElement("span");
role.className = "draco-apps-item-role";
role.textContent = petit.textContent;
txt.appendChild(role);
}
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
});
bar.appendChild(menu);
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
buildMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+14 -1
View File
@@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
class FakeGitea:
"""Drop-in async replacement for GiteaClient used by the API tests."""
kind = "gitea"
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.connection_id = 1
self.connection_slug = "gitea"
self.key_prefix = ""
self.starred = set()
self.repos = [
_repo("demo", now - timedelta(days=1)),
@@ -136,9 +141,17 @@ class FakeGitea:
results.append({**issue, "repository": {"full_name": full_name}})
return results
async def set_archived(self, owner, repo, archived):
for r in self.repos:
if r["full_name"] == f"{owner}/{repo}":
r["archived"] = archived
return {"archived": archived}
async def delete_label(self, owner, repo, label_id):
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
for name in [n for n, l in labels.items() if l["id"] == label_id]:
for name in [
n for n, l in labels.items() if str(l["id"]) == str(label_id)
]:
del labels[name]
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
+13
View File
@@ -39,6 +39,19 @@ def test_board_hides_pull_requests_and_reconciles(client):
assert [c["issue_number"] for c in state["done"]] == [3] # reconciled
def test_board_renders_closed_issue_in_done_column(client):
# Regression: reconcile moved the closed issue's row to "done", but
# the board merged open issues only, so the card never rendered and
# the "Terminé" column stayed empty.
models.move_issue("Projets/demo", 3, "todo") # closed in Gitea
r = client.get("/board/Projets/demo")
assert r.status_code == 200
assert "Ancienne tâche" in r.text
payload = client.get("/api/board/Projets/demo").json()
assert payload["columns"]["done"]["count"] == 1
assert "Ancienne tâche" in payload["columns"]["done"]["html"]
def test_focus_lists_in_progress_across_projects(client):
models.move_issue("Projets/demo", 5, "in_progress")
r = client.get("/focus")
+64
View File
@@ -0,0 +1,64 @@
"""Fusion ACC-1 — barre d'apps commune (carte kanbante#28).
Le snippet figé de dracodev-commons est vendangé (CSS/JS statiques +
partiel _draco_bar.html) et inclus par le cadre partagé _header.html :
toutes les pages applicatives l'affichent avec Kanbante comme app
courante ; l'écran de connexion, autonome, ne l'affiche pas.
"""
def test_barre_presente_sur_le_dashboard(client):
r = client.get("/")
assert r.status_code == 200
assert 'class="draco-bar"' in r.text
assert 'data-current="kanbante"' in r.text
assert "draco-barre.css" in r.text
assert "draco-barre.js" in r.text
assert 'window.DRACO_BAR_CONFIG' in r.text
# Les quatre applications de la barre, aux URL publiques.
for url in ("https://og.dracodev.net/", "https://flowdeck.dracodev.net/",
"https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"):
assert url in r.text
def test_statiques_de_la_barre_servis(client):
assert client.get("/static/css/draco-barre.css").status_code == 200
assert client.get("/static/js/draco-barre.js").status_code == 200
def test_menu_des_applications_par_la_marque(client):
# v0.1.3 : la marque ✦ porte data-act="apps" et le JS commun
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
accueil = client.get("/")
assert 'data-act="apps"' in accueil.text
# 0.23.3 : assets versionnés, sinon le cache garde l'ancien JS.
assert "draco-barre.css?v=" in accueil.text
assert "draco-barre.js?v=" in accueil.text
js = client.get("/static/js/draco-barre.js").text
assert "draco-apps-menu" in js
css = client.get("/static/css/draco-barre.css").text
assert "font-size: 24px" in css
def test_barre_pleine_largeur_et_palier_mobile(client):
# 0.23.1 : la barre est fixe pleine largeur (le padding du cadre
# ne la repousse plus) et le palier mobile commons v0.1.2 ne
# garde que l'onglet de l'app courante.
style = client.get("/static/css/style.css").text
assert "body.has-draco-bar .draco-bar" in style
assert "position: fixed" in style
barre = client.get("/static/css/draco-barre.css").text
assert '.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"]' in barre
def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea):
from fastapi.testclient import TestClient
import app.main as main
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
r = client.get("/login")
assert r.status_code == 200
assert "draco-bar" not in r.text
+103
View File
@@ -0,0 +1,103 @@
"""Fusion ACC-3.2 — pont SSO draco_session (carte kanbante#27).
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
(secret partagé, HS256) et converti en session Kanbante native par
GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide
ouvre la session de l'opérateur configuré. Sans
DRACO_SESSION_SECRET, le pont renvoie simplement au login.
"""
import base64
import hashlib
import hmac
import json
import time
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import draco
SECRET = "secret-partage-de-test"
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"):
def b64(d: bytes) -> str:
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({
"sub": sub, "name": "Bruno", "iat": int(time.time()),
"exp": exp if exp is not None else int(time.time()) + 3600,
"jti": jti,
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
@pytest.fixture()
def client_sso(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
draco._revoked_cache.clear()
with TestClient(main.app) as client:
yield client
draco._revoked_cache.clear()
def test_verify_token(monkeypatch):
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
assert draco.verify_token(_jeton())["sub"] == "admin-1"
assert draco.verify_token(_jeton(secret="mauvais")) is None
assert draco.verify_token(_jeton(exp=int(time.time()) - 5)) is None
assert draco.verify_token("pas.un.jeton") is None
def test_pont_sans_cookie_vers_login(client_sso):
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/login"
def test_pont_cookie_valide_ouvre_la_session(client_sso):
client_sso.cookies.set("draco_session", _jeton())
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/"
assert "kanbante_session" in r.headers.get("set-cookie", "")
# La session native créée par le pont donne accès à l'app.
assert client_sso.get("/").status_code == 200
def test_pont_cookie_falsifie_vers_login(client_sso):
client_sso.cookies.set("draco_session", _jeton(secret="mauvais"))
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.headers["location"] == "/login"
def test_pont_jeton_revoque_refuse(client_sso):
draco._revoked_cache["jti-revoque"] = int(time.time()) + 3600
client_sso.cookies.set("draco_session", _jeton(jti="jti-revoque"))
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.headers["location"] == "/login"
def test_pont_inerte_sans_secret(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
monkeypatch.delenv("DRACO_SESSION_SECRET", raising=False)
with TestClient(main.app) as client:
client.cookies.set("draco_session", _jeton())
r = client.get("/auth/draco", follow_redirects=False)
assert r.headers["location"] == "/login"
def test_bouton_sso_sur_la_page_login(client_sso):
r = client_sso.get("/login")
assert r.status_code == 200
assert "og.dracodev.net/sso/authorize" in r.text
assert "kanbante.dracodev.net%2Fauth%2Fdraco" in r.text
+117
View File
@@ -0,0 +1,117 @@
"""GitHubClient (0.24.0, P1): same protocol as GiteaClient,
GitHub wire shape underneath. Driven through MockTransport."""
import httpx
import pytest
from app.github_client import GitHubClient
def make_client(handler) -> GitHubClient:
return GitHubClient(
base_url="https://api.github.com",
token="ghp_test",
cache_ttl=0,
transport=httpx.MockTransport(handler),
)
async def test_auth_header_is_bearer():
seen = {}
def handler(request):
seen["auth"] = request.headers.get("authorization")
seen["accept"] = request.headers.get("accept")
return httpx.Response(200, json={"login": "octocat"})
client = make_client(handler)
user = await client.get_user()
assert user["login"] == "octocat"
assert seen["auth"] == "Bearer ghp_test"
assert "application/vnd.github+json" in seen["accept"]
async def test_pagination_follows_link_headers():
def handler(request):
if "page=2" in str(request.url):
return httpx.Response(200, json=[{"full_name": "a/deux"}])
return httpx.Response(
200,
json=[{"full_name": "a/un"}],
headers={
"Link": '<https://api.github.com/user/repos?page=2>; rel="next"'
},
)
client = make_client(handler)
repos = await client._fetch_search_repos()
assert [r["full_name"] for r in repos] == ["a/un", "a/deux"]
async def test_labels_are_addressed_by_name():
seen = {}
def handler(request):
if request.method == "GET" and request.url.path.endswith("/labels"):
return httpx.Response(
200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}]
)
if request.method == "POST":
seen["body"] = request.content.decode()
return httpx.Response(200, json=[])
raise AssertionError(request.url)
client = make_client(handler)
label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6")
assert label["id"] == "kanban:todo" # name, not GitHub's numeric id
await client.add_issue_label("a", "b", 7, label["id"])
assert '"kanban:todo"' in seen["body"]
async def test_search_issues_normalizes_repository():
def handler(request):
assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url)
return httpx.Response(
200,
json={
"items": [
{
"number": 3,
"title": "T",
"repository_url": "https://api.github.com/repos/a/b",
}
]
},
)
client = make_client(handler)
issues = await client.search_issues(q="mot", state="open")
assert issues[0]["repository"]["full_name"] == "a/b"
async def test_set_archived_uses_patch():
seen = {}
def handler(request):
seen["method"] = request.method
seen["body"] = request.content.decode()
return httpx.Response(200, json={"archived": True})
client = make_client(handler)
await client.set_archived("a", "b", True)
assert seen["method"] == "PATCH"
assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"]
async def test_starred_roundtrip():
seen = {}
def handler(request):
seen["method"] = request.method
seen["path"] = request.url.path
return httpx.Response(204)
client = make_client(handler)
await client.set_starred("a", "b", True)
assert seen["method"] == "PUT"
assert seen["path"] == "/user/starred/a/b"
+1 -1
View File
@@ -70,7 +70,7 @@ def test_nav_js_contract(client, fake_gitea):
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
async def fake_collect(only_repo):
async def fake_collect(only_repo, *args, **kwargs):
return [{
"full_name": "Projets/demo",
"counts": {c: 0 for c in
+360
View File
@@ -0,0 +1,360 @@
"""Palier 19 (0.24.0): multi-forges, utilisateurs, espaces,
paramètres en base, archives — P0 à P4 de l'analyse du 2026-10-21."""
import sqlite3
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, forge_crypto, models
from tests.conftest import csrf_from
# ── Migration des anciennes bases (P0) ────────────────────
def test_migration_legacy_schema_gains_scope(tmp_path, monkeypatch):
db = tmp_path / "legacy.db"
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name, issue_number)
);
CREATE TABLE project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name)
);
CREATE TABLE today_picks (
pick_date TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
);
CREATE TABLE board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_column TEXT,
to_column TEXT,
source TEXT NOT NULL DEFAULT 'ui',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO board_state (repo_full_name, issue_number, column_name)
VALUES ('Projets/demo', 1, 'todo');
INSERT INTO project_notes (repo_full_name, note)
VALUES ('Projets/demo', 'note héritée');
"""
)
conn.commit()
conn.close()
monkeypatch.setenv("KANBANTE_DB_PATH", str(db))
models.init_db()
board = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in board["todo"]] == [1]
assert models.get_notes("Projets/demo") == "note héritée"
# La portée par défaut est (1, 1) et les nouvelles tables existent.
assert models.list_connections() == []
assert models.count_users() == 0
# ── Chiffrement des jetons (P0) ───────────────────────────
def test_forge_token_encryption_roundtrip(monkeypatch):
from cryptography.fernet import Fernet
monkeypatch.setenv("KANBANTE_MASTER_KEY", Fernet.generate_key().decode())
forge_crypto._fernet = None # forcer la relecture de la clé
stored = forge_crypto.encrypt_token("ghp_secret")
assert stored != "ghp_secret"
assert forge_crypto.decrypt_token(stored) == "ghp_secret"
assert forge_crypto.mask_token(stored) == "••••••••"
assert forge_crypto.encrypt_token("") == ""
# ── Amorçage (P0/P2/P3) ───────────────────────────────────
@pytest.fixture
def auth_client(tmp_path, monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
yield client
def _login(client, username="bruno", password="motdepasse"):
client.get("/login")
token = client.cookies.get("kanbante_csrf") or ""
return client.post(
"/login",
data={
"username": username,
"password": password,
"next": "/",
"csrf_token": token,
},
follow_redirects=False,
)
def test_bootstrap_creates_legacy_connection_admin_workspace(auth_client):
connections = models.list_connections()
assert len(connections) == 1
assert connections[0]["slug"] == "gitea"
assert connections[0]["kind"] == "gitea"
workspaces = models.list_workspaces()
assert [ws["slug"] for ws in workspaces] == ["principal"]
assert models.workspace_connection_ids(workspaces[0]["id"]) == [
connections[0]["id"]
]
users = models.list_users()
assert len(users) == 1
assert users[0]["username"] == "bruno"
assert users[0]["role"] == "admin"
assert accounts.verify_password("motdepasse", users[0]["password_hash"])
def test_db_login_and_password_change(auth_client):
# Le mot de passe d'environnement a créé le hash en base :
# la connexion passe par la base.
r = _login(auth_client)
assert r.status_code == 303
token = csrf_from(auth_client)
r = auth_client.post(
"/profile/password",
data={
"current_password": "motdepasse",
"new_password": "nouveau-secret",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
auth_client.get("/logout")
# Le nouveau mot de passe fonctionne…
assert _login(auth_client, password="nouveau-secret").status_code == 303
auth_client.get("/logout")
# …et pour un compte membre, l'ancien ne passe plus (pas de
# secours environnement pour les membres).
token = csrf_from(auth_client)
_login(auth_client, password="nouveau-secret")
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "alice",
"password": "alice-pass",
"role": "member",
"display_name": "Alice",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
assert _login(auth_client, "alice", "alice-pass").status_code == 303
token = csrf_from(auth_client)
auth_client.post(
"/profile/password",
data={
"current_password": "alice-pass",
"new_password": "alice-nouveau",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
r = _login(auth_client, "alice", "alice-pass")
assert r.status_code == 401
assert _login(auth_client, "alice", "alice-nouveau").status_code == 303
def test_member_cannot_open_admin(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "bob",
"password": "bob-pass",
"role": "member",
"display_name": "",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
_login(auth_client, "bob", "bob-pass")
assert auth_client.get("/admin").status_code == 403
assert auth_client.get("/admin/connections").status_code == 403
assert auth_client.get("/profile").status_code == 200
def test_disabled_user_session_dies(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "eve",
"password": "eve-pass",
"role": "member",
"display_name": "",
"csrf_token": token,
},
follow_redirects=False,
)
eve_id = models.get_user_by_username("eve")["id"]
auth_client.get("/logout")
_login(auth_client, "eve", "eve-pass")
assert auth_client.get("/profile").status_code == 200
# L'admin désactive Eve pendant que sa session est ouverte.
with TestClient(main.app) as admin_client:
_login(admin_client)
token = csrf_from(admin_client)
admin_client.post(
f"/admin/users/{eve_id}/toggle",
data={"csrf_token": token},
follow_redirects=False,
)
r = auth_client.get(
"/profile", headers={"Accept": "text/html"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
def test_admin_creates_github_connection_encrypted(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/connections",
data={
"kind": "github",
"name": "GitHub perso",
"base_url": "https://api.github.com",
"token": "ghp_secret_token",
"hook_secret": "",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
row = models.get_connection_by_slug("github-perso")
assert row is not None
assert row["kind"] == "github"
assert row["token_encrypted"] != "ghp_secret_token"
assert forge_crypto.decrypt_token(row["token_encrypted"]) == "ghp_secret_token"
page = auth_client.get("/admin/connections")
assert "ghp_secret_token" not in page.text # jamais réaffiché
assert "••••••••" in page.text
# ── Paramètres en base (P3) ───────────────────────────────
def test_settings_panel_overrides_env(auth_client):
from app.config import get_settings
_login(auth_client)
assert get_settings().aging_days == 7
token = csrf_from(auth_client)
auth_client.post(
"/admin/settings",
data={"setting_aging_days": "42", "csrf_token": token},
follow_redirects=False,
)
assert get_settings().aging_days == 42
# Champ vidé = retour à l'environnement.
token = csrf_from(auth_client)
auth_client.post(
"/admin/settings",
data={"setting_aging_days": "", "csrf_token": token},
follow_redirects=False,
)
assert get_settings().aging_days == 7
# ── Espaces et portées (P3) ───────────────────────────────
def test_board_state_is_scoped_per_workspace():
models.move_issue("Projets/demo", 1, "todo", workspace_id=1, connection_id=1)
models.move_issue("Projets/demo", 1, "review", workspace_id=2, connection_id=1)
assert models.get_board_state("Projets/demo", workspace_id=1)["todo"]
board2 = models.get_board_state("Projets/demo", workspace_id=2)
assert board2["review"] and not board2["todo"]
def test_workspace_switch_scopes_dashboard(client, fake_gitea):
# Un espace vide (aucune connexion) ne montre aucun projet.
empty_id = models.create_workspace("vide", "Vide", "#999999")
client.cookies.set("kanbante_workspace", str(empty_id))
page = client.get("/")
assert "/board/Projets/demo" not in page.text
principal = models.list_workspaces()[0]
client.cookies.set("kanbante_workspace", str(principal["id"]))
page = client.get("/")
assert "/board/Projets/demo" in page.text
# ── Archives (P4) ─────────────────────────────────────────
def test_local_archive_hides_and_restores(client, fake_gitea):
token = csrf_from(client)
principal = models.list_workspaces()[0]
assert "/board/Projets/demo" in client.get("/").text
r = client.post(
"/archives/local",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
assert "/board/Projets/demo" not in client.get("/").text
archives = client.get("/archives")
assert "Projets/demo" in archives.text
assert models.list_local_archived(principal["id"])
token = csrf_from(client)
client.post(
"/archives/local",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "0",
"csrf_token": token,
},
follow_redirects=False,
)
assert "/board/Projets/demo" in client.get("/").text
def test_forge_archive_via_page(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/archives/forge",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
repo = next(r for r in fake_gitea.repos if r["full_name"] == "Projets/demo")
assert repo["archived"] is True