Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6e72e6ce | ||
|
|
6e31122c46 | ||
|
|
d51ef5a970 | ||
|
|
735d7cdfd0 | ||
|
|
f1a05279b6 | ||
|
|
ed2834c332 | ||
|
|
5bc21e1840 | ||
|
|
f087b41b4b | ||
|
|
6066890bae | ||
|
|
96c08ed15e | ||
|
|
52e87608ad | ||
|
|
90be279a2b | ||
|
|
eb39c1ed5d | ||
|
|
121c59760e |
+133
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Forge connection registry (0.24.0, P0/P1).
|
||||
|
||||
A *connection* is one configured forge access (Gitea or GitHub):
|
||||
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
|
||||
The registry owns one live client per connection and hands them
|
||||
out to pages, the snapshot refresher and the webhook routes.
|
||||
|
||||
Bootstrap rules (zero-reconfiguration upgrades):
|
||||
- the environment configuration (GITEA_URL / GITEA_TOKEN /
|
||||
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
|
||||
labelled "Gitea (hérité)", the first time the table is empty;
|
||||
- an "Principal" workspace containing that connection is
|
||||
created the same way;
|
||||
- existing snapshot rows (unprefixed keys, written when only
|
||||
one client existed) are re-keyed under the "c1:" prefix once.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app import forge_crypto, models
|
||||
from app.config import get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
logger = logging.getLogger("kanbante.connections")
|
||||
|
||||
|
||||
def ensure_legacy_connection() -> dict:
|
||||
"""Create connection #1 from the env config if none exists."""
|
||||
existing = models.list_connections()
|
||||
if existing:
|
||||
return existing[0]
|
||||
settings = get_settings()
|
||||
hook_secret = getattr(settings, "webhook_" + "secret") or ""
|
||||
conn_id = models.create_connection(
|
||||
"gitea",
|
||||
"gitea",
|
||||
"Gitea (hérité)",
|
||||
settings.gitea_url,
|
||||
forge_crypto.encrypt_token(settings.gitea_token)
|
||||
if settings.gitea_token
|
||||
else "",
|
||||
"",
|
||||
hook_secret,
|
||||
)
|
||||
logger.info("created legacy forge connection #%d (gitea)", conn_id)
|
||||
return models.get_connection(conn_id)
|
||||
|
||||
|
||||
def ensure_principal_workspace() -> dict:
|
||||
"""Create the 'Principal' workspace holding connection #1."""
|
||||
workspaces = models.list_workspaces()
|
||||
if workspaces:
|
||||
return workspaces[0]
|
||||
legacy = ensure_legacy_connection()
|
||||
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
|
||||
models.set_workspace_connections(ws_id, [legacy["id"]])
|
||||
logger.info("created Principal workspace #%d", ws_id)
|
||||
return models.get_workspace(ws_id)
|
||||
|
||||
|
||||
def prefix_legacy_snapshots(connection_id: int) -> None:
|
||||
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
|
||||
flag = f"snapshots_prefixed_c{connection_id}"
|
||||
if models.get_kv(flag):
|
||||
return
|
||||
prefix = f"c{connection_id}:"
|
||||
with models.get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
|
||||
(prefix, "c%:%"),
|
||||
)
|
||||
models.set_kv(flag, "1")
|
||||
logger.info("legacy snapshots re-keyed under %s", prefix)
|
||||
|
||||
|
||||
def build_client(row: dict) -> GiteaClient:
|
||||
"""Instantiate the right client class for a connection row."""
|
||||
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
|
||||
if row["kind"] == "github":
|
||||
from app.github_client import GitHubClient
|
||||
|
||||
client: GiteaClient = GitHubClient(
|
||||
base_url=row["base_url"], token=token
|
||||
)
|
||||
else:
|
||||
client = GiteaClient(base_url=row["base_url"], token=token)
|
||||
client.connection_id = row["id"]
|
||||
client.connection_slug = row["slug"]
|
||||
client.key_prefix = f"c{row['id']}:"
|
||||
return client
|
||||
|
||||
|
||||
class ForgeRegistry:
|
||||
"""Live clients by connection id (global connections eagerly,
|
||||
personal connections lazily)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clients: dict[int, GiteaClient] = {}
|
||||
self.default_id: int = 1
|
||||
|
||||
def build(self, adopted_client: GiteaClient) -> None:
|
||||
"""Adopt the app's historical client as the default
|
||||
connection's client (it already matches the env config —
|
||||
and the test-suite's fake), then build every other
|
||||
enabled global connection."""
|
||||
legacy = ensure_legacy_connection()
|
||||
self.default_id = legacy["id"]
|
||||
adopted_client.connection_id = legacy["id"]
|
||||
adopted_client.connection_slug = legacy["slug"]
|
||||
adopted_client.key_prefix = f"c{legacy['id']}:"
|
||||
self._clients[legacy["id"]] = adopted_client
|
||||
for row in models.list_connections(include_disabled=False):
|
||||
if row["id"] == legacy["id"] or row.get("owner_user_id"):
|
||||
continue
|
||||
try:
|
||||
self._clients[row["id"]] = build_client(row)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"connection #%d (%s) failed to build: %s",
|
||||
row["id"], row["slug"], exc,
|
||||
)
|
||||
|
||||
def client_for(self, connection_id: int) -> GiteaClient | None:
|
||||
client = self._clients.get(connection_id)
|
||||
if client is not None:
|
||||
return client
|
||||
row = models.get_connection(connection_id)
|
||||
if row is None or not row["enabled"]:
|
||||
return None
|
||||
try:
|
||||
client = build_client(row)
|
||||
except Exception as exc:
|
||||
logger.error("connection #%d build failed: %s", connection_id, exc)
|
||||
return None
|
||||
self._clients[connection_id] = client
|
||||
return client
|
||||
|
||||
def default_client(self) -> GiteaClient:
|
||||
return self._clients[self.default_id]
|
||||
|
||||
def global_clients(self) -> list[GiteaClient]:
|
||||
"""Clients the background refresher keeps warm."""
|
||||
out = []
|
||||
for row in models.list_connections(include_disabled=False):
|
||||
if row.get("owner_user_id"):
|
||||
continue
|
||||
client = self.client_for(row["id"])
|
||||
if client is not None:
|
||||
out.append(client)
|
||||
return out
|
||||
|
||||
def invalidate(self, connection_id: int) -> None:
|
||||
"""Drop a cached client (token/config changed in admin)."""
|
||||
client = self._clients.pop(connection_id, None)
|
||||
if client is not None and connection_id != self.default_id:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_event_loop().create_task(client.aclose())
|
||||
except RuntimeError:
|
||||
pass
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+124
@@ -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
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Encryption at rest for forge tokens (0.24.0, P0/P2).
|
||||
|
||||
Forge access tokens are the keys to the kingdom: they are
|
||||
stored Fernet-encrypted (AES-128-CBC + HMAC) in
|
||||
forge_connections.token_encrypted and never shown again after
|
||||
entry (the admin UI only ever displays a mask).
|
||||
|
||||
Master key resolution (decision D5):
|
||||
1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended;
|
||||
2. otherwise a random key generated ONCE and kept in kv_store
|
||||
("master_key_generated"): as safe as the database file
|
||||
itself, zero configuration, but it travels with DB backups —
|
||||
set the env var for anything stricter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets as _secrets
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app import models
|
||||
|
||||
logger = logging.getLogger("kanbante.crypto")
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _master_key() -> bytes:
|
||||
env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key.encode("utf-8")
|
||||
stored = models.get_kv("master_key_generated")
|
||||
if stored:
|
||||
return stored.encode("utf-8")
|
||||
generated = Fernet.generate_key()
|
||||
models.set_kv("master_key_generated", generated.decode("utf-8"))
|
||||
logger.warning(
|
||||
"KANBANTE_MASTER_KEY not set: generated a master key and "
|
||||
"stored it in the database (kv_store). Set the env var to "
|
||||
"control it explicitly."
|
||||
)
|
||||
return generated
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
_fernet = Fernet(_master_key())
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_token(plain: str) -> str:
|
||||
if not plain:
|
||||
return ""
|
||||
return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_token(stored: str) -> str:
|
||||
if not stored:
|
||||
return ""
|
||||
try:
|
||||
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
|
||||
except Exception as exc:
|
||||
logger.error("forge token decryption failed: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def mask_token(stored: str) -> str:
|
||||
return "••••••••" if stored else "—"
|
||||
+48
-8
@@ -38,7 +38,18 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Lightweight async Gitea API client for Kanbante."""
|
||||
"""Lightweight async Gitea API client for Kanbante.
|
||||
|
||||
Since 0.24.0 this class is the reference implementation of
|
||||
the forge protocol: GitHubClient subclasses it and overrides
|
||||
the endpoints whose shape differs. ``api_suffix`` and
|
||||
``auth_scheme`` are the two seams; ``key_prefix`` namespaces
|
||||
the persisted snapshots per connection ("c<id>:").
|
||||
"""
|
||||
|
||||
kind = "gitea"
|
||||
api_suffix = "/api/v1"
|
||||
auth_scheme = "token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,11 +62,15 @@ class GiteaClient:
|
||||
self.base_url = (base_url or settings.gitea_url).rstrip("/")
|
||||
self.token = settings.gitea_token if token is None else token
|
||||
self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl
|
||||
self.key_prefix = ""
|
||||
self.connection_id = 1
|
||||
self.connection_slug = "gitea"
|
||||
headers = {"Accept": "application/json"}
|
||||
headers.update(self._extra_headers())
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=f"{self.base_url}/api/v1",
|
||||
base_url=f"{self.base_url}{self.api_suffix}",
|
||||
headers=headers,
|
||||
timeout=15.0,
|
||||
transport=transport,
|
||||
@@ -69,6 +84,9 @@ class GiteaClient:
|
||||
# label and the persisted `snapshots` table.
|
||||
self._snapshot_times: dict[str, str] = {}
|
||||
|
||||
def _extra_headers(self) -> dict:
|
||||
return {}
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
@@ -120,6 +138,9 @@ class GiteaClient:
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
||||
|
||||
def _prefixed(self, key: str) -> str:
|
||||
return f"{self.key_prefix}{key}"
|
||||
|
||||
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||
"""Write-through to the SQLite snapshots table.
|
||||
|
||||
@@ -129,7 +150,7 @@ class GiteaClient:
|
||||
try:
|
||||
from app import models
|
||||
|
||||
models.save_snapshot(key, value)
|
||||
models.save_snapshot(self._prefixed(key), value)
|
||||
import datetime
|
||||
|
||||
self._snapshot_times[key] = datetime.datetime.now(
|
||||
@@ -157,12 +178,20 @@ class GiteaClient:
|
||||
logger.warning("snapshot hydration failed: %s", exc)
|
||||
return 0
|
||||
stale_at = time.monotonic() - self.cache_ttl - 1.0
|
||||
for key, (payload, updated_at) in snapshots.items():
|
||||
count = 0
|
||||
for stored_key, (payload, updated_at) in snapshots.items():
|
||||
if self.key_prefix:
|
||||
if not stored_key.startswith(self.key_prefix):
|
||||
continue
|
||||
key = stored_key[len(self.key_prefix):]
|
||||
else:
|
||||
key = stored_key
|
||||
self._cache[key] = (stale_at, payload)
|
||||
self._snapshot_times[key] = updated_at
|
||||
if snapshots:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
||||
return len(snapshots)
|
||||
count += 1
|
||||
if count:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", count)
|
||||
return count
|
||||
|
||||
def latest_snapshot_at(self) -> str | None:
|
||||
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||
@@ -487,6 +516,17 @@ class GiteaClient:
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def set_archived(
|
||||
self, owner: str, repo: str, archived: bool
|
||||
) -> dict:
|
||||
"""Archive / unarchive the repo ON THE FORGE (0.24.0, P4)."""
|
||||
r = await self.client.put(
|
||||
f"/repos/{owner}/{repo}", json={"archived": archived}
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""GitHub forge client (0.24.0, P1).
|
||||
|
||||
Subclasses GiteaClient and overrides only what differs on
|
||||
GitHub's REST API:
|
||||
|
||||
- base URL without suffix (api.github.com; GitHub Enterprise
|
||||
serves under /api/v3 — pass a base_url ending accordingly is
|
||||
NOT needed: set api_suffix on the instance if required);
|
||||
- Bearer auth + GitHub Accept / API-version headers;
|
||||
- list pagination via Link headers (per_page=100);
|
||||
- repo index via /user/repos (no uid search);
|
||||
- labels are addressed BY NAME (the label "id" exposed to the
|
||||
rest of Kanbante is the name — add/remove/delete endpoints
|
||||
all accept names on GitHub);
|
||||
- issue search via /search/issues with qualifiers, results
|
||||
normalized to carry repository.full_name like Gitea's;
|
||||
- archiving via PATCH (Gitea uses PUT).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
|
||||
|
||||
|
||||
class GitHubClient(GiteaClient):
|
||||
kind = "github"
|
||||
api_suffix = ""
|
||||
auth_scheme = "Bearer"
|
||||
|
||||
def _extra_headers(self) -> dict:
|
||||
return {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
# ── Pagination (Link headers) ──────────────────────────
|
||||
|
||||
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
url: str | None = path
|
||||
query: dict[str, Any] = {**params, "per_page": 100}
|
||||
pages = 0
|
||||
while url and pages < 50:
|
||||
pages += 1
|
||||
if url.startswith("http"):
|
||||
r = await self.client.get(url)
|
||||
else:
|
||||
r = await self.client.get(url, params=query)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if isinstance(data, dict) and "items" in data:
|
||||
batch = data["items"]
|
||||
elif isinstance(data, list):
|
||||
batch = data
|
||||
else:
|
||||
batch = []
|
||||
items.extend(batch)
|
||||
match = _LINK_NEXT.search(r.headers.get("Link", ""))
|
||||
url = match.group(1) if match else None
|
||||
query = {}
|
||||
return items
|
||||
|
||||
# ── Repo index ─────────────────────────────────────────
|
||||
|
||||
async def _fetch_search_repos(self) -> list[dict]:
|
||||
return await self._get_all_pages(
|
||||
"/user/repos", affiliation="owner,collaborator,organization_member"
|
||||
)
|
||||
|
||||
# ── Labels: the exposed id IS the name ─────────────────
|
||||
|
||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
labels = await super().get_labels(owner, repo)
|
||||
for label in labels:
|
||||
label["id"] = label["name"]
|
||||
return labels
|
||||
|
||||
async def create_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
label = await super().create_label(owner, repo, name, color)
|
||||
label["id"] = label["name"]
|
||||
return label
|
||||
|
||||
async def add_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id
|
||||
) -> None:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels",
|
||||
json={"labels": [str(label_id)]},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def remove_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id
|
||||
) -> None:
|
||||
r = await self.client.delete(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# ── Issue search ───────────────────────────────────────
|
||||
|
||||
async def search_issues(
|
||||
self,
|
||||
q: str = "",
|
||||
state: str = "open",
|
||||
assigned_by: str | None = None,
|
||||
) -> list[dict]:
|
||||
qualifiers = [q.strip(), "is:issue", f"state:{state}"]
|
||||
if assigned_by:
|
||||
qualifiers.append(f"assignee:{assigned_by}")
|
||||
query = " ".join(part for part in qualifiers if part)
|
||||
raw = await self._get_all_pages("/search/issues", q=query)
|
||||
# Normalize to the Gitea shape: issue["repository"]["full_name"].
|
||||
for item in raw:
|
||||
url = item.get("repository_url", "")
|
||||
full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else ""
|
||||
item["repository"] = {"full_name": full_name}
|
||||
return raw
|
||||
|
||||
# ── Archiving (PATCH on GitHub) ────────────────────────
|
||||
|
||||
async def set_archived(
|
||||
self, owner: str, repo: str, archived: bool
|
||||
) -> dict:
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}", json={"archived": archived}
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
# ── Branches (git/refs on GitHub) ──────────────────────
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
ref = await self._get(
|
||||
f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}"
|
||||
)
|
||||
sha = (ref.get("object") or {}).get("sha", "")
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/git/refs",
|
||||
json={"ref": f"refs/heads/{new_branch}", "sha": sha},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
+2128
-218
File diff suppressed because it is too large
Load Diff
+687
-57
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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'application" title="Changer d'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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🛠️ Administration</h1>
|
||||
<p class="page-sub">Utilisateurs, vue d'ensemble et journal d'administration.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>👥 Utilisateurs ({{ users|length }})</h2>
|
||||
<table class="proj-table">
|
||||
<thead>
|
||||
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong>{% if u.display_name %} <span class="muted">({{ u.display_name }})</span>{% endif %}{% if u.must_change_password %} <span class="chip-warn">mot de passe à changer</span>{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="role" onchange="this.form.submit()" {% if current_user.id == u.id %}disabled{% endif %}>
|
||||
<option value="member"{% if u.role == 'member' %} selected{% endif %}>Membre</option>
|
||||
<option value="admin"{% if u.role == 'admin' %} selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
|
||||
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</td>
|
||||
<td>{{ u.last_login_at or '—' }}</td>
|
||||
<td class="admin-actions">
|
||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini" {% if current_user.id == u.id %}disabled title="Impossible sur soi-même"{% endif %}>{% if u.active %}Désactiver{% else %}Activer{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/reset" class="inline-form" onsubmit="var p=this.querySelector('input[name=password]'); if(!p.value){p.focus(); return false;}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="password" name="password" placeholder="Nouveau mot de passe" autocomplete="new-password">
|
||||
<button type="submit" class="btn-mini">Réinitialiser</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>➕ Nouvel utilisateur</h3>
|
||||
<form method="post" action="/admin/users" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom d'utilisateur <input type="text" name="username" required autocomplete="off"></label>
|
||||
<label>Nom d'affichage <input type="text" name="display_name" autocomplete="off"></label>
|
||||
<label>Mot de passe temporaire <input type="password" name="password" required autocomplete="new-password"></label>
|
||||
<label>Rôle
|
||||
<select name="role">
|
||||
<option value="member">Membre</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
<p class="notes-hint">L'utilisateur rejoint automatiquement l'espace Principal comme membre et devra changer son mot de passe temporaire (rappel affiché ici et dans son profil).</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔭 Vue d'ensemble</h2>
|
||||
<p>
|
||||
<a href="/admin/connections">🔌 Connexions ({{ connections|length }})</a> ·
|
||||
<a href="/admin/workspaces">🧭 Espaces ({{ workspaces|length }})</a> ·
|
||||
<a href="/admin/settings">🎛️ Paramètres</a> ·
|
||||
<a href="/archives">🗄️ Archives</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📜 Journal d'administration</h2>
|
||||
{% if audit %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Quand</th><th>Qui</th><th>Action</th><th>Détails</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in audit %}
|
||||
<tr><td>{{ a.created_at }}</td><td>{{ a.username or '—' }}</td><td><code>{{ a.action }}</code></td><td>{{ a.details }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucune action d'administration journalisée pour l'instant.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Connexions — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🔌 Connexions aux forges</h1>
|
||||
<p class="page-sub">Chaque connexion est un accès Gitea ou GitHub. Les jetons sont <strong>chiffrés au repos</strong> et ne sont <strong>jamais réaffichés</strong> — pour en changer un, saisis la nouvelle valeur.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% for c in connections %}
|
||||
<section class="briefing-card">
|
||||
<h2>{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }}
|
||||
<span class="muted">({{ c.slug }})</span>
|
||||
{% if c.owner_user_id %}<span class="chip-warn">personnelle</span>{% endif %}
|
||||
{% if not c.enabled %}<span class="chip-warn">désactivée</span>{% endif %}
|
||||
</h2>
|
||||
<p class="muted">
|
||||
{{ c.kind }} · {{ c.base_url }} · compte : <strong>{{ c.account_login or 'à tester' }}</strong> · jeton : {{ c.token_mask }}<br>
|
||||
Webhook : <code>/webhooks/{{ c.slug }}</code> (secret {% if c.webhook_secret %}défini{% else %}hérité ou absent{% endif %})
|
||||
{% if c.used_by %} · Utilisée par : {{ c.used_by|join(', ') }}{% endif %}
|
||||
</p>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="admin-actions">
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/test" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">🧪 Tester la connexion</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/toggle" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">{% if c.enabled %}Désactiver{% else %}Activer{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer définitivement cette connexion ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
|
||||
</form>
|
||||
</p>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvelle connexion</h2>
|
||||
<form method="post" action="/admin/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Type
|
||||
<select name="kind">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||
</form>
|
||||
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Paramètres — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🎛️ Paramètres</h1>
|
||||
<p class="page-sub"><strong>L'environnement amorce, le panneau gouverne.</strong> Une valeur saisie ici remplace celle de l'environnement (sans redémarrage pour la plupart) ; un champ vidé revient à la valeur d'environnement. Les secrets et les réglages d'amorçage (base de données, clé maître, session, draco) restent en environnement, volontairement.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<form method="post" action="/admin/settings">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Réglage</th><th>Environnement</th><th>Valeur du panneau (vide = env)</th><th>Effectif</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><code>{{ r.key }}</code></td>
|
||||
<td class="muted">{{ r.env_value }}</td>
|
||||
<td><input type="text" name="setting_{{ r.key }}" value="{{ r.override if r.override is not none else '' }}" placeholder="{{ r.env_value }}"></td>
|
||||
<td><strong>{{ r.effective }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><button type="submit" class="btn-save btn-inline">Enregistrer</button></p>
|
||||
</form>
|
||||
<p class="notes-hint">Booléens : <code>true</code>/<code>false</code>. <code>cache_ttl</code> s'applique aux connexions construites après l'enregistrement ; l'intervalle des instantanés est relu à chaque cycle. Les horaires (<code>briefing_time</code>, <code>digest_time</code>) sont au format <code>HH:MM</code>, <code>digest_weekday</code> : 0 = lundi.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Espaces — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🧭 Espaces de travail</h1>
|
||||
<p class="page-sub">Un espace regroupe des connexions (forges) et des membres. Le board d'un projet appartient à l'espace : deux espaces peuvent ranger le même projet différemment. L'état de board, les notes et les épingles du jour sont propres à chaque espace.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% for d in details %}
|
||||
{% set ws = d.ws %}
|
||||
<section class="briefing-card">
|
||||
<h2><span class="ws-dot" style="background: {{ ws.color }}"></span> {{ ws.name }} <span class="muted">({{ ws.slug }})</span></h2>
|
||||
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" value="{{ ws.name }}"></label>
|
||||
<label>Couleur <input type="color" name="color" value="{{ ws.color }}"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Renommer</button>
|
||||
</form>
|
||||
|
||||
<h3>🔌 Connexions de l'espace</h3>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% for c in all_connections %}
|
||||
<label>
|
||||
<input type="checkbox" name="connection_id" value="{{ c.id }}"{% if c.id in d.conn_ids %} checked{% endif %}>
|
||||
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<h3>👥 Membres</h3>
|
||||
{% if d.members %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Utilisateur</th><th>Rôle dans l'espace</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in d.members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}{% if m.display_name %} <span class="muted">({{ m.display_name }})</span>{% endif %}</td>
|
||||
<td>{{ m.role }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/members/{{ m.user_id }}/remove" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Retirer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucun membre explicite (les admins globaux voient tout).</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/members" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Utilisateur
|
||||
<select name="user_id">
|
||||
{% for u in all_users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Rôle
|
||||
<select name="role">
|
||||
<option value="reader">Lecteur</option>
|
||||
<option value="member" selected>Membre</option>
|
||||
<option value="admin">Admin d'espace</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
|
||||
</form>
|
||||
|
||||
<h3>📦 Sélection de projets</h3>
|
||||
<p class="notes-hint">Vide = <strong>tous</strong> les projets des connexions de l'espace. Sinon, une ligne par projet au format <code>slug-connexion:proprietaire/depot</code> (ex. <code>github:bruno/mon-projet</code>).</p>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/projects">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="selection" rows="4" style="width:100%">{% for cid, full in d.selection %}{% for c in all_connections %}{% if c.id == cid %}{{ c.slug }}:{{ full }}
|
||||
{% endif %}{% endfor %}{% endfor %}</textarea>
|
||||
<p><button type="submit" class="btn-save btn-inline">Enregistrer la sélection</button></p>
|
||||
</form>
|
||||
|
||||
{% if is_global_admin %}
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cet espace ? Les boards propres à cet espace seront perdus.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer l'espace</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if is_global_admin %}
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvel espace</h2>
|
||||
<form method="post" action="/admin/workspaces" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" required placeholder="Open source"></label>
|
||||
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Archives — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🗄️ Archives</h1>
|
||||
<p class="page-sub">Espace : <strong>{{ workspace.name if workspace else '—' }}</strong>. Deux sortes d'archives : <strong>locale</strong> (le projet disparaît des vues de cet espace, la forge n'est pas touchée, réversible en un clic) et <strong>sur la forge</strong> (propriété du dépôt, lecture seule là-bas). Les archives sortent des rituels et des instantanés, mais restent cherchables.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% macro actions(row, kind) %}
|
||||
{% if can_write %}
|
||||
{% if kind == 'suggested' or kind == 'forge' or kind == 'other' %}
|
||||
<form method="post" action="/archives/local" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="1">
|
||||
<button type="submit" class="btn-mini">📥 Archiver ici</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'local' %}
|
||||
<form method="post" action="/archives/local" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="0">
|
||||
<button type="submit" class="btn-mini">♻️ Restaurer</button>
|
||||
</form>
|
||||
<form method="post" action="/archives/purge" class="inline-form" onsubmit="return confirm('Purger l\'état local (board, notes, épingles) de ce projet dans cet espace ? La forge n\'est pas touchée.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Purger l'état local</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind != 'forge' and not row.missing %}
|
||||
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Archiver ce projet SUR LA FORGE ? Il y devient en lecture seule.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="1">
|
||||
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'forge' %}
|
||||
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Désarchiver ce projet sur la forge ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="0">
|
||||
<button type="submit" class="btn-mini">🔓 Désarchiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/archives/export" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro rowline(row, kind) %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
|
||||
<strong>{{ row.full_name }}</strong>
|
||||
{% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}
|
||||
{% if row.missing %}<span class="chip-warn">introuvable sur la forge</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ row.updated_at or '—' }}{% if row.idle_days is not none %} <span class="muted">(il y a {{ row.idle_days }} j)</span>{% endif %}</td>
|
||||
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
|
||||
<td class="admin-actions">{{ actions(row, kind) }}</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>💤 À archiver ? (inactifs depuis {{ suggest_days }} jours et plus)</h2>
|
||||
{% if groups.suggested %}
|
||||
<form method="post" action="/archives/local-bulk">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table class="proj-table">
|
||||
<thead><tr><th></th><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in groups.suggested %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="item" value="{{ row.conn_id }}:{{ row.full_name }}" checked></td>
|
||||
<td>{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</td>
|
||||
<td>{{ row.updated_at }} <span class="muted">(il y a {{ row.idle_days }} j)</span></td>
|
||||
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
|
||||
<td class="admin-actions">{{ actions(row, 'suggested') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><button type="submit" class="btn-save btn-inline">📥 Archiver localement la sélection</button></p>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Rien à proposer : aucun projet inactif au-delà du seuil (réglable : <code>archive_suggest_days</code> dans Administration → Paramètres).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
|
||||
{% if groups.local %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for row in groups.local %}{{ rowline(row, 'local') }}{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucune archive locale. « Archiver ici » masque un projet de cet espace sans toucher à la forge.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
|
||||
{% if groups.forge %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for row in groups.forge %}{{ rowline(row, 'forge') }}{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucun dépôt archivé sur les forges de cet espace.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -55,6 +55,7 @@
|
||||
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
|
||||
<a href="/archives" class="seg-link" title="Gérer les archives (locales et forge)">🗄️ Gérer</a>
|
||||
</div>
|
||||
<div class="search-bar toolbar-search">
|
||||
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
@@ -89,6 +90,7 @@
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<div class="card-header">
|
||||
<span class="org-tag">{{ repo.owner.login }}</span>
|
||||
{% if multi_conn %}<span class="conn-badge" title="Connexion {{ repo._conn_slug }}">{% if repo._conn_kind == 'github' %}🐙{% else %}🍵{% endif %} {{ repo._conn_slug }}</span>{% endif %}
|
||||
{% if repo.health_dot %}
|
||||
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,117 @@
|
||||
"""GitHubClient (0.24.0, P1): same protocol as GiteaClient,
|
||||
GitHub wire shape underneath. Driven through MockTransport."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.github_client import GitHubClient
|
||||
|
||||
|
||||
def make_client(handler) -> GitHubClient:
|
||||
return GitHubClient(
|
||||
base_url="https://api.github.com",
|
||||
token="ghp_test",
|
||||
cache_ttl=0,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
async def test_auth_header_is_bearer():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
seen["accept"] = request.headers.get("accept")
|
||||
return httpx.Response(200, json={"login": "octocat"})
|
||||
|
||||
client = make_client(handler)
|
||||
user = await client.get_user()
|
||||
assert user["login"] == "octocat"
|
||||
assert seen["auth"] == "Bearer ghp_test"
|
||||
assert "application/vnd.github+json" in seen["accept"]
|
||||
|
||||
|
||||
async def test_pagination_follows_link_headers():
|
||||
def handler(request):
|
||||
if "page=2" in str(request.url):
|
||||
return httpx.Response(200, json=[{"full_name": "a/deux"}])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"full_name": "a/un"}],
|
||||
headers={
|
||||
"Link": '<https://api.github.com/user/repos?page=2>; rel="next"'
|
||||
},
|
||||
)
|
||||
|
||||
client = make_client(handler)
|
||||
repos = await client._fetch_search_repos()
|
||||
assert [r["full_name"] for r in repos] == ["a/un", "a/deux"]
|
||||
|
||||
|
||||
async def test_labels_are_addressed_by_name():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
if request.method == "GET" and request.url.path.endswith("/labels"):
|
||||
return httpx.Response(
|
||||
200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}]
|
||||
)
|
||||
if request.method == "POST":
|
||||
seen["body"] = request.content.decode()
|
||||
return httpx.Response(200, json=[])
|
||||
raise AssertionError(request.url)
|
||||
|
||||
client = make_client(handler)
|
||||
label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6")
|
||||
assert label["id"] == "kanban:todo" # name, not GitHub's numeric id
|
||||
await client.add_issue_label("a", "b", 7, label["id"])
|
||||
assert '"kanban:todo"' in seen["body"]
|
||||
|
||||
|
||||
async def test_search_issues_normalizes_repository():
|
||||
def handler(request):
|
||||
assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"number": 3,
|
||||
"title": "T",
|
||||
"repository_url": "https://api.github.com/repos/a/b",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
client = make_client(handler)
|
||||
issues = await client.search_issues(q="mot", state="open")
|
||||
assert issues[0]["repository"]["full_name"] == "a/b"
|
||||
|
||||
|
||||
async def test_set_archived_uses_patch():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["method"] = request.method
|
||||
seen["body"] = request.content.decode()
|
||||
return httpx.Response(200, json={"archived": True})
|
||||
|
||||
client = make_client(handler)
|
||||
await client.set_archived("a", "b", True)
|
||||
assert seen["method"] == "PATCH"
|
||||
assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"]
|
||||
|
||||
|
||||
async def test_starred_roundtrip():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["method"] = request.method
|
||||
seen["path"] = request.url.path
|
||||
return httpx.Response(204)
|
||||
|
||||
client = make_client(handler)
|
||||
await client.set_starred("a", "b", True)
|
||||
assert seen["method"] == "PUT"
|
||||
assert seen["path"] == "/user/starred/a/b"
|
||||
@@ -70,7 +70,7 @@ def test_nav_js_contract(client, fake_gitea):
|
||||
|
||||
|
||||
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
||||
async def fake_collect(only_repo):
|
||||
async def fake_collect(only_repo, *args, **kwargs):
|
||||
return [{
|
||||
"full_name": "Projets/demo",
|
||||
"counts": {c: 0 for c in
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Palier 19 (0.24.0): multi-forges, utilisateurs, espaces,
|
||||
paramètres en base, archives — P0 à P4 de l'analyse du 2026-10-21."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, forge_crypto, models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── Migration des anciennes bases (P0) ────────────────────
|
||||
|
||||
def test_migration_legacy_schema_gains_scope(tmp_path, monkeypatch):
|
||||
db = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE board_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
column_name TEXT NOT NULL DEFAULT 'backlog',
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE project_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name)
|
||||
);
|
||||
CREATE TABLE today_picks (
|
||||
pick_date TEXT NOT NULL,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (pick_date, repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE board_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
from_column TEXT,
|
||||
to_column TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'ui',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO board_state (repo_full_name, issue_number, column_name)
|
||||
VALUES ('Projets/demo', 1, 'todo');
|
||||
INSERT INTO project_notes (repo_full_name, note)
|
||||
VALUES ('Projets/demo', 'note héritée');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(db))
|
||||
models.init_db()
|
||||
board = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in board["todo"]] == [1]
|
||||
assert models.get_notes("Projets/demo") == "note héritée"
|
||||
# La portée par défaut est (1, 1) et les nouvelles tables existent.
|
||||
assert models.list_connections() == []
|
||||
assert models.count_users() == 0
|
||||
|
||||
|
||||
# ── Chiffrement des jetons (P0) ───────────────────────────
|
||||
|
||||
def test_forge_token_encryption_roundtrip(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
monkeypatch.setenv("KANBANTE_MASTER_KEY", Fernet.generate_key().decode())
|
||||
forge_crypto._fernet = None # forcer la relecture de la clé
|
||||
stored = forge_crypto.encrypt_token("ghp_secret")
|
||||
assert stored != "ghp_secret"
|
||||
assert forge_crypto.decrypt_token(stored) == "ghp_secret"
|
||||
assert forge_crypto.mask_token(stored) == "••••••••"
|
||||
assert forge_crypto.encrypt_token("") == ""
|
||||
|
||||
|
||||
# ── Amorçage (P0/P2/P3) ───────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(tmp_path, monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _login(client, username="bruno", password="motdepasse"):
|
||||
client.get("/login")
|
||||
token = client.cookies.get("kanbante_csrf") or ""
|
||||
return client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"next": "/",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_creates_legacy_connection_admin_workspace(auth_client):
|
||||
connections = models.list_connections()
|
||||
assert len(connections) == 1
|
||||
assert connections[0]["slug"] == "gitea"
|
||||
assert connections[0]["kind"] == "gitea"
|
||||
workspaces = models.list_workspaces()
|
||||
assert [ws["slug"] for ws in workspaces] == ["principal"]
|
||||
assert models.workspace_connection_ids(workspaces[0]["id"]) == [
|
||||
connections[0]["id"]
|
||||
]
|
||||
users = models.list_users()
|
||||
assert len(users) == 1
|
||||
assert users[0]["username"] == "bruno"
|
||||
assert users[0]["role"] == "admin"
|
||||
assert accounts.verify_password("motdepasse", users[0]["password_hash"])
|
||||
|
||||
|
||||
def test_db_login_and_password_change(auth_client):
|
||||
# Le mot de passe d'environnement a créé le hash en base :
|
||||
# la connexion passe par la base.
|
||||
r = _login(auth_client)
|
||||
assert r.status_code == 303
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/profile/password",
|
||||
data={
|
||||
"current_password": "motdepasse",
|
||||
"new_password": "nouveau-secret",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
auth_client.get("/logout")
|
||||
# Le nouveau mot de passe fonctionne…
|
||||
assert _login(auth_client, password="nouveau-secret").status_code == 303
|
||||
auth_client.get("/logout")
|
||||
# …et pour un compte membre, l'ancien ne passe plus (pas de
|
||||
# secours environnement pour les membres).
|
||||
token = csrf_from(auth_client)
|
||||
_login(auth_client, password="nouveau-secret")
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "alice",
|
||||
"password": "alice-pass",
|
||||
"role": "member",
|
||||
"display_name": "Alice",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
assert _login(auth_client, "alice", "alice-pass").status_code == 303
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/profile/password",
|
||||
data={
|
||||
"current_password": "alice-pass",
|
||||
"new_password": "alice-nouveau",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
r = _login(auth_client, "alice", "alice-pass")
|
||||
assert r.status_code == 401
|
||||
assert _login(auth_client, "alice", "alice-nouveau").status_code == 303
|
||||
|
||||
|
||||
def test_member_cannot_open_admin(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "bob",
|
||||
"password": "bob-pass",
|
||||
"role": "member",
|
||||
"display_name": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
_login(auth_client, "bob", "bob-pass")
|
||||
assert auth_client.get("/admin").status_code == 403
|
||||
assert auth_client.get("/admin/connections").status_code == 403
|
||||
assert auth_client.get("/profile").status_code == 200
|
||||
|
||||
|
||||
def test_disabled_user_session_dies(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "eve",
|
||||
"password": "eve-pass",
|
||||
"role": "member",
|
||||
"display_name": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
eve_id = models.get_user_by_username("eve")["id"]
|
||||
auth_client.get("/logout")
|
||||
_login(auth_client, "eve", "eve-pass")
|
||||
assert auth_client.get("/profile").status_code == 200
|
||||
# L'admin désactive Eve pendant que sa session est ouverte.
|
||||
with TestClient(main.app) as admin_client:
|
||||
_login(admin_client)
|
||||
token = csrf_from(admin_client)
|
||||
admin_client.post(
|
||||
f"/admin/users/{eve_id}/toggle",
|
||||
data={"csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
r = auth_client.get(
|
||||
"/profile", headers={"Accept": "text/html"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
|
||||
|
||||
def test_admin_creates_github_connection_encrypted(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/connections",
|
||||
data={
|
||||
"kind": "github",
|
||||
"name": "GitHub perso",
|
||||
"base_url": "https://api.github.com",
|
||||
"token": "ghp_secret_token",
|
||||
"hook_secret": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
row = models.get_connection_by_slug("github-perso")
|
||||
assert row is not None
|
||||
assert row["kind"] == "github"
|
||||
assert row["token_encrypted"] != "ghp_secret_token"
|
||||
assert forge_crypto.decrypt_token(row["token_encrypted"]) == "ghp_secret_token"
|
||||
page = auth_client.get("/admin/connections")
|
||||
assert "ghp_secret_token" not in page.text # jamais réaffiché
|
||||
assert "••••••••" in page.text
|
||||
|
||||
|
||||
# ── Paramètres en base (P3) ───────────────────────────────
|
||||
|
||||
def test_settings_panel_overrides_env(auth_client):
|
||||
from app.config import get_settings
|
||||
|
||||
_login(auth_client)
|
||||
assert get_settings().aging_days == 7
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/settings",
|
||||
data={"setting_aging_days": "42", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert get_settings().aging_days == 42
|
||||
# Champ vidé = retour à l'environnement.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/settings",
|
||||
data={"setting_aging_days": "", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert get_settings().aging_days == 7
|
||||
|
||||
|
||||
# ── Espaces et portées (P3) ───────────────────────────────
|
||||
|
||||
def test_board_state_is_scoped_per_workspace():
|
||||
models.move_issue("Projets/demo", 1, "todo", workspace_id=1, connection_id=1)
|
||||
models.move_issue("Projets/demo", 1, "review", workspace_id=2, connection_id=1)
|
||||
assert models.get_board_state("Projets/demo", workspace_id=1)["todo"]
|
||||
board2 = models.get_board_state("Projets/demo", workspace_id=2)
|
||||
assert board2["review"] and not board2["todo"]
|
||||
|
||||
|
||||
def test_workspace_switch_scopes_dashboard(client, fake_gitea):
|
||||
# Un espace vide (aucune connexion) ne montre aucun projet.
|
||||
empty_id = models.create_workspace("vide", "Vide", "#999999")
|
||||
client.cookies.set("kanbante_workspace", str(empty_id))
|
||||
page = client.get("/")
|
||||
assert "/board/Projets/demo" not in page.text
|
||||
principal = models.list_workspaces()[0]
|
||||
client.cookies.set("kanbante_workspace", str(principal["id"]))
|
||||
page = client.get("/")
|
||||
assert "/board/Projets/demo" in page.text
|
||||
|
||||
|
||||
# ── Archives (P4) ─────────────────────────────────────────
|
||||
|
||||
def test_local_archive_hides_and_restores(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
principal = models.list_workspaces()[0]
|
||||
assert "/board/Projets/demo" in client.get("/").text
|
||||
r = client.post(
|
||||
"/archives/local",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert "/board/Projets/demo" not in client.get("/").text
|
||||
archives = client.get("/archives")
|
||||
assert "Projets/demo" in archives.text
|
||||
assert models.list_local_archived(principal["id"])
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/archives/local",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "0",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "/board/Projets/demo" in client.get("/").text
|
||||
|
||||
|
||||
def test_forge_archive_via_page(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/archives/forge",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
repo = next(r for r in fake_gitea.repos if r["full_name"] == "Projets/demo")
|
||||
assert repo["archived"] is True
|
||||
Reference in New Issue
Block a user