Compare commits

..
Author SHA1 Message Date
bruno 5d10b02537 Tests Kanbante : origines agent en CORS recherche
CI / test (push) Failing after 0s
CI / test (pull_request) Failing after 0s
2026-10-28 16:06:14 -04:00
bruno 739672f0cc Kanbante : CORS recherche autorise les origines agent (federation console, defaut 2)
CI / test (push) Failing after 0s
2026-10-28 16:06:12 -04:00
92 changed files with 704 additions and 4080 deletions
-2
View File
@@ -12,14 +12,12 @@ KANBANTE_PUBLIC_URL=https://kanbante.dracodev.net
DRACO_SSO_URL=https://og.dracodev.net
DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
DRACO_TIKTAK_URL=https://tiktak.dracodev.net
DRACO_SHAARIT_URL=https://shaarit.dracodev.net
AGENT_URL=https://agent.dracodev.net
# Exemples dev (à décommenter sur l'hôte dev) :
# KANBANTE_PUBLIC_URL=https://kanbante-dev.dracodev.net
# DRACO_SSO_URL=https://og-dev.dracodev.net
# DRACO_FLOWDECK_URL=https://flowdeck-dev.dracodev.net
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
# DRACO_SHAARIT_URL=https://shaarit-dev.dracodev.net
# AGENT_URL=https://agent-dev.dracodev.net
# Journal d'événements du service d'agents (signé HMAC)
# AGENT_EVENEMENTS_URL= # vide = dérivé de AGENT_URL (/api/v1/evenements)
-192
View File
@@ -1,195 +1,3 @@
## 0.46.4 — 2026-11-06 — Barre commune v0.6.0 re-vendangée : entrée « Administration » (fusion #142)
- **Re-vendange verbatim** de la barre commons v0.6.0 (PR dracodev-commons #14) : `static/js/draco-barre.js` + `static/css/draco-barre.css`. Le menu compte gagne l'entrée **Administration** → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), à **visibilité conditionnelle** (affirmée par l'hôte via `cfg.admin` / `DRACO_BAR.setAdmin`, jamais déduite par la barre).
- **Pas de drapeau chez Kanbante** : Kanbante ne connaît que le rôle du registre du Hub pour sa propre application (consulté avant la création JIT de la correspondance) — pas le rôle global de suite (rôle du compte chez ObsiGate, la définition de la garde du Hub). À défaut de certitude, le contrat v0.6.0 impose de ne pas affirmer : l'entrée reste masquée dans Kanbante (elle apparaît chez ObsiGate et dans la console, qui peuvent l'établir). Commentaire posé dans le gabarit `_draco_bar.html`.
- **Tests** : nouveau `tests/test_barre_v060.py` (marqueurs de la vendange + absence d'affirmation dans le gabarit).
## 0.46.3 — 2026-11-06 — Déconnexion du menu compte : locale **et** globale (fusion #141)
- **Défaut corrigé** : Kanbante était le seul hôte à brancher l'action `deconnexion` de la barre commune, mais sa branche ne faisait que naviguer vers `GET /logout` — la session locale était détruite, **la session draco (SSO) survivait** : le pont `/auth/draco` rouvrait une session Kanbante sans mot de passe. Une « déconnexion » qui ne déconnectait pas vraiment.
- **Branchement corrigé** (`app/templates/_draco_bar.html`) : destruction d'abord de la session locale (`fetch GET /logout`), puis navigation vers la déconnexion globale de l'émettrice (`GET {draco_urls.obsigate}/sso/logout?return_to={origine}/`, nouvelle route ObsiGate 2.86.0, PR sœur) : le `jti` draco y est révoqué et le cookie effacé sur le domaine parent. Après le parcours, la session de la suite entière et la session locale Kanbante sont terminées ; le retour se fait sur la racine Kanbante (page de connexion).
- **Test** : nouveau `tests/test_deconnexion.py` (marqueurs du branchement + disparition de l'ancien branchement local seul). Périmètre draco/barre : 33 passés (environnement local : variables `NO_PROXY`/proxy de la VM à écarter pour httpx, fonds connu).
## 0.46.2 — 2026-11-06 — Rôles du Hub propagés à Kanbante (HUB-19, suite du pilote ShaarIt 0.7.0)
Version **corrective** au sens du patron du dépôt (pas de fonction nouvelle côté métier : un garde d'accès branché sur le registre du Hub, contrat D3(a) du plan Hub v2).
- **Client du registre** (`app/hub_roles.py`, portage Python du pilote `hub_roles.rs` de ShaarIt) : à l'ouverture de session draco, consultation de `GET {hub}/api/roles/kanbante` en relayant l'en-tête Cookie complet de la requête (`Accept: application/json`, délai 2 s) → rôle ∈ {`aucun`, `utilisateur`, `administrateur`}. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel (échec = ancien cache conservé, pas de rafale). URL du Hub résolue comme la barre commune (`resoudre_urls_draco` : `DRACO_HUB_URL` explicite > hôte public de la requête > dérivation par l'hôte sœur de l'agent > défaut officiel) — jamais d'URL dev figée. **Toute erreur = rôle inconnu → comportement actuel** : le registre ne crée jamais un verrouillage nouveau.
- **Pont `/auth/draco` — consultation d'abord** : le registre est consulté **avant** la liaison à une session native, avant `resolve_draco_user()` et donc avant toute création JIT (HUB-16). Rôle connu `aucun` → refus explicite en français (403, page d'erreur) : **aucune session Kanbante ouverte par draco pour ce `sub`, aucun compte créé, aucune liaison posée**. La session ouverte par le pont porte désormais un marqueur « établie par draco » **couvert par la signature** du cookie de session (`user.expiry.draco.hmac` ; le format natif historique reste lu tel quel).
- **`/admin` gardé par le registre pour les sessions draco** : `_require_admin` (qui couvre `/admin` et toutes ses actions) exige, pour une session établie par draco dont le rôle du registre est **connu**, le niveau `administrateur` du registre **en plus** du rôle local admin. Rôle inconnu ou session native → règles locales actuelles, inchangées.
- **Non touchés** : session native préexistante, connexion native (mot de passe), Basic d'environnement, jetons `kbt_`, `/api/search`, droits internes et réconciliation Gitea par étiquettes (le piège « exactement une étiquette reconnue » n'est pas le sujet de cette carte). Détails : `docs/correspondance-draco.md`, section « Rôles du Hub (HUB-19) ».
- Tests : `tests/test_hub_roles.py` — client (contrat d'appel, cookie relayé, cache et TTL, échec sans rafale avec ancien cache conservé, réponses illisibles, résolution d'URL par hôte dev et par variable explicite), marqueur de session signé, pont (refus `aucun` sans création ni liaison, JIT `utilisateur`, repli rôle inconnu) et garde `/admin` (session draco `utilisateur` refusée malgré le rôle local admin, `administrateur` accepté, inconnu = repli local, session native non soumise).
## 0.46.1 — 2026-11-06 — Barre commune v0.5.0 : thème commun (HUB-18, fusion #132)
> **Hypothèse d'ordre** : cette version suppose la fusion préalable de la PR #84 (Kanbante 0.46.0, HUB-16) ; branchée sur `main` (0.45.12), elle vise **0.46.1**.
- **Barre d'apps v0.5.0 vendangée (dracodev-commons, branche `hub18-theme-commun`, PR commons #12)** : `static/js/draco-barre.js` remplacé **verbatim** — SHA-256 JS `384e2b02…6e188ce11c7` (preuve dans `tests/test_barre_v050.py`). Le CSS est inchangé en v0.5.0 (empreinte v0.4.1 conservée ; `tests/test_barre_v041.py` supersédé pour le seul JS).
- **Thème commun** : la barre apprend la préférence de thème du profil du Hub (`GET <hub>/api/profil/theme`, cookie draco ; `<hub>` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : l'amorce actuelle (choix local `kanbante-theme`, défaut sombre) reste seule maître.
- **Initialisation propre à Kanbante** (`static/js/nav.js`) : écoute de l'événement `draco:theme` de la barre (+ reprise de `DRACO_BAR.themeMode` au démarrage, la barre s'exécutant avant nav.js) — le mode effectif est posé sur `data-theme` et le bouton de bascule est resynchronisé (`syncThemeBtn`). Le choix local `kanbante-theme` n'est **pas** écrasé : le bouton reste le maître pour la session.
- **Cache** : le bump du fichier `VERSION` change le `?v=` des CSS/JS de la barre ; `APP_VERSION` suit automatiquement (source unique `VERSION`).
- Tests : `tests/test_barre_v050.py` (verbatim v0.5.0 prouvé par SHA-256, marqueurs thème, initialisation nav.js).
## 0.46.0 — 2026-11-06 — Correspondance draco par utilisateur (HUB-16, carte fusion #130)
Version **mineure** : c'est une fonction (nouveau comportement d'identité), selon le patron du dépôt (fonctions en mineur — 0.24.0 comptes, 0.25.0 jetons ; les 0.45.x étaient des vendanges et correctifs).
- **Fin de l'hypothèse mono-utilisateur du pont SSO (ACC-3.2)** : un jeton `draco_session` valide désigne désormais **le compte Kanbante de son identité**. La correspondance vit dans la colonne existante `users.draco_sub` (UNIQUE, depuis la 0.24.0) ; la résolution est centralisée dans `app.accounts.resolve_draco_user()`, seul point d'entrée du pont `/auth/draco` et du contrôle draco de `/api/search`.
- **Création JIT** : à la première connexion draco d'une identité sans correspondance, son compte Kanbante est créé automatiquement — nom dérivé des claims (slug du nom affiché, suffixé en cas de collision, jamais d'adoption d'un compte non lié), rôle **membre**, mot de passe local aléatoire haché **non divulgué** (l'accès normal passe par draco), membre de l'espace Principal, geste journalisé (`draco.jit`).
- **Compte d'amorçage** : si l'unique compte existant est un admin actif sans liaison, le premier `sub` valide s'y lie au premier passage (journalisé `draco.link.amorcage`) — le compte actuel de l'opérateur garde son accès ; ses voies natives (mot de passe du compte, repli environnement formulaire/Basic) sont inchangées.
- **Liaison par l'administrateur** : colonne « Draco (sub) » de `/admin` — saisir le `sub` et « Lier », ou « Délier » ; refus si le `sub` est déjà lié à un autre compte ; permis sur son propre compte (voie manuelle pour l'amorçage). Journalisé (`draco.link.admin` / `draco.unlink.admin`). Procédure et modèle : `docs/correspondance-draco.md`. Le registre matriciel côté Hub viendra avec HUB-19 ; ici, seul le côté Kanbante — la source de vérité locale — est livré.
- **`/api/search`** : le cookie draco n'y vaut plus « opérateur » — le `sub` doit désigner un compte (correspondance ou amorçage) ; aucun JIT sur cette surface.
- **Non-régressions** : jetons personnels `kbt_` (création, usage `/mcp` et `/api/`, révocation), sessions propres Kanbante, connexion native (avec liaison à la première visite doublement connectée), authentification Basic, pont inerte sans `DRACO_SESSION_SECRET`, compte désactivé refusé partout sans contournement JIT. La réconciliation par étiquettes Kanbante/Gitea n'est pas touchée.
- Tests : `tests/test_draco_correspondance.py` (amorçage unique admin, deux identités → deux comptes distincts, JIT sans adoption d'un compte homonyme non lié, liaison/déliaison admin et conflit de `sub`, compte désactivé refusé, `/api/search` par utilisateur, jeton `kbt_` toujours accepté) ; le docstring de `tests/test_draco_sso.py` est mis à jour (le pont n'est plus mono-utilisateur).
## 0.45.12 — 2026-11-05 — Barre commune v0.4.1 : correctif des icônes invisibles du menu compte (fusion #125)
- **Barre d'apps v0.4.1 vendangée (dracodev-commons, branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11)** : `static/js/draco-barre.js` remplacé **verbatim** — SHA-256 JS `82c4940e…b34f85a42` (preuve dans `tests/test_barre_v041.py`). Le CSS est inchangé (empreinte v0.4.0 conservée) ; le gabarit `_draco_bar.html` est inchangé.
- **Correctif** : en v0.4.0, `menuIcone()` créait le `<svg>` des icônes et ses primitives via `document.createElement()` — espace de noms **HTML**, jamais dessinés par le navigateur : les libellés du menu compte étaient décalés d'un cran d'icône, sans aucun glyphe. La v0.4.1 crée tout via `document.createElementNS()` (espace de noms SVG). Glyphes, CSS et comportements inchangés.
- **Cache** : le bump du fichier `VERSION` change le `?v=` des CSS/JS de la barre ; `APP_VERSION` suit automatiquement (source unique `VERSION`).
- Tests : `tests/test_barre_v041.py` (verbatim v0.4.1 prouvé par SHA-256, création en espace de noms SVG) ; l'empreinte JS de `tests/test_barre_v040.py` est supersédée selon le patron habituel.
## 0.45.11 — 2026-11-05 — Barre commune v0.4.0 : icônes monoline du menu compte (HUB-11)
- **Barre d'apps v0.4.0 vendangée (dracodev-commons, branche `menu-compte-icones-v0-4-0`, PR commons #10)** : `static/css/draco-barre.css` et `static/js/draco-barre.js` remplacés **verbatim** — SHA-256 CSS `d8cf53b2…a6bd06ec`, JS `36cc24b2…e5d39a0` (preuve dans `tests/test_barre_v040.py`). Le gabarit `_draco_bar.html` est inchangé.
- **Menu compte à icônes** : **Télécharger**, **À Propos**, **Aides**, **Profil** et **Déconnexion** reçoivent des **icônes SVG monoline** (contour, trait fin, `currentColor`, ~18 px) dans le style des icônes du rail de la console Spark — aucun emoji. L'en-tête d'identité reste sans icône ; les liens Hub (Profil → Hub compris) et le relais « Déconnexion » sont inchangés.
- **Cache** : le bump du fichier `VERSION` change le `?v=` des CSS/JS de la barre ; `APP_VERSION` suit automatiquement (source unique `VERSION`).
- Tests : `tests/test_barre_v040.py` (verbatim v0.4.0, marqueurs icônes) ; l'empreinte v0.3.0 de `tests/test_barre_v030.py` devient une preuve de supersession (patron HUB-1).
## 0.45.10 — 2026-11-04 — Barre commune v0.3.0 : liens Hub du menu compte (HUB-1)
> Renumérotée 0.45.10 à la publication : le numéro 0.45.9 avait été consommé entre-temps par la release du correctif « source de version » (voir l'entrée suivante), publiée avant la fusion de la PR #81.
- **⚠ CHANGEMENT DE COMPORTEMENT VISIBLE — « Profil » du menu compte ne mène plus à `/profile`** : Kanbante était le seul hôte à traiter l'action relayée `profil` (vers sa page `/profile` propre). La barre v0.3.0 fait de Profil un **lien vers le Hub** (`<hub>/profil`) — décision du plan Hub : un seul Profil, celui du Hub. La branche `onAction("profil")` du gabarit `_draco_bar.html` est donc **retirée**. La page `/profile` elle-même **continue d'exister** (jetons API, réglages du compte Kanbante) et reste joignable par son URL ; « Déconnexion » reste relayée vers `/logout`, inchangée. Signalé explicitement pour arbitrage à la fusion.
- **Barre d'apps v0.3.0 vendangée (dracodev-commons, branche `hub-menu-compte-v0-3-0`, PR commons #9)** : `static/css/draco-barre.css` et `static/js/draco-barre.js` remplacés **verbatim** — SHA-256 CSS `4ac2ed2e…9475b0`, JS `9d84617b…6c74da5` (preuve dans `tests/test_barre_v030.py`). Fragment `_draco_bar.html` porté selon le mécanisme d'adaptation Kanbante existant.
- **Menu compte** : liens **Télécharger**, **À Propos**, **Aides** et **Profil** construits par le JS vendangé depuis la base Hub résolue. Le Hub n'a pas d'onglet.
- **URL Hub par le même mécanisme que les autres apps** (`app.config` : champ `hub_url` dans `Settings` et `UrlsDraco`) : explicite `DRACO_HUB_URL` > hôte public de la requête (motif fermé étendu à `hub<SUFFIXE>.dracodev.net`) > dérivation depuis les adresses de l'agent connues > défaut officiel `https://hub.dracodev.net` (dev : `https://hub-dev.dracodev.net`). `app/main.py` expose `draco_urls.hub` au gabarit, passé en `urls.hub`.
- **Cache** : le bump du fichier `VERSION` change le `?v=` des CSS/JS de la barre ; `APP_VERSION` suit automatiquement depuis le correctif 0.45.9 (lecture du fichier `VERSION`, source unique).
- Tests : `tests/test_barre_v030.py` (verbatim v0.3.0, résolution Hub, gabarit sans branche `profil`, `/profile` toujours routée) ; le test d'empreinte v0.2.0 de `tests/test_barre_v020.py` devient une preuve de supersession.
## 0.45.9 — 2026-11-04 — Correctif : source de version unique (fichier `VERSION`)
- `APP_VERSION` lit désormais le fichier `VERSION` (repli `/VERSION` dans l'image Docker, `COPY` ajouté au Dockerfile) au lieu d'une constante dans le code — la santé et l'affichage suivent toujours le fichier. Release publiée le 2026-11-04 (tag sur le commit du correctif), avant la fusion de la barre v0.3.0 (PR #81, parue en 0.45.10).
## 0.45.7 — 2026-11-04 — Recherche fédérée : ShaarIt, 5e source de « Rechercher partout »
- **`recherche.js` re-vendangé depuis dracodev-commons v0.2.1** (source canonique `ui/recherche/`) : ShaarIt devient une source de la page `/recherche` comme les autres — bases prod/dev, onglet, normaliseur (lien profond canonique `{base}shaare/<shorturl>`, contexte « Bookmark · privé · tags », repli recherche native `/?q=`). Le fichier reste **strictement identique** chez les cinq hôtes.
- **Gabarit `/recherche`** : onglet et section « ShaarIt — bookmarks », `data-base-shaarit` rendu par `draco_urls.shaarit` (déjà résolu par `render()`), aide mise à jour.
- **CORS de recherche** (`app/recherche.py`) : origines officielles ShaarIt prod et dev ajoutées aux listes explicites ; `shaarit` ajoutée aux clés sœurs et au dictionnaire résolu (`urls.shaarit_url`) — `GET /api/search` de Kanbante devient lisible depuis les pages de ShaarIt.
- Tests : `tests/recherche.test.js` porté à 5 sections (83 assertions, dont le normaliseur ShaarIt) ; `tests/test_recherche.py` — allowlist attendue à 12 origines.
- **Déploiement** : comme les versions précédentes, le conteneur Kanbante se redéploie par un geste de Bruno (la publication seule ne le redémarre pas).
## 0.45.6 — 2026-11-03 — Barre commune v0.2.0 : onglet ShaarIt + garde data-theme (phase 3 ShaarIt)
- **⚠ CHANGEMENT DE COMPORTEMENT VISIBLE — garde `data-theme` (arbitrage à la fusion)** : le CSS de barre vendangé jusqu'ici (v0.1.11) appliquait les couleurs **sombres dès que le système était en mode sombre**, sans tenir compte du thème clair choisi dans l'app — sur un poste en sombre système, la barre restait sombre alors que Kanbante était en clair. La v0.2.0 de commons corrige ce piège **dans la source** : les deux blocs `@media (prefers-color-scheme: dark)` sont gardés par `html:not([data-theme])` (le sombre système ne s'applique que si `<html>` n'a **aucun** `data-theme`). Kanbante posant toujours `html[data-theme]` (sombre par défaut, bascule mémorisée `kanbante-theme`), sa barre suivra désormais **toujours le thème de l'app**, jamais celui du système contre son choix. Aucune surcharge `style.css` ajoutée : le correctif est entièrement dans le CSS vendangé verbatim.
- **Barre d'apps v0.2.0 vendangée (dracodev-commons @70c837a, PR commons #7)** : `static/css/draco-barre.css` et `static/js/draco-barre.js` remplacés **verbatim** — SHA-256 CSS `abc0c3b2…e05fab`, JS `24efe399…6e50e1`, identiques à la source `ui/barre-apps/` @70c837a (preuve aussi dans `tests/test_barre_v020.py`). Fragment `_draco_bar.html` porté à la v0.2.0 selon le mécanisme d'adaptation Kanbante existant (URL Jinja `draco_urls` / `agent_url`, `onAction` complet inchangé).
- **Nouvel onglet ShaarIt** (5e app, bookmarks) : `data-app="shaarit"`, **après Kanbante, avant Console Spark** ; couleur émeraude `#059669` ; logo `static/img/logos/shaarit.svg` copié verbatim de commons (SHA-256 `faf0c8a1…316db34`). Le menu de la marque, construit depuis les onglets, l'inclut automatiquement ; le palier mobile du CSS gagne la règle `data-current="shaarit"` (sans effet chez Kanbante, hôte `kanbante`).
- **URL ShaarIt par le même mécanisme que les autres apps** (`app.config` : nouveau champ `shaarit_url` dans `Settings` et `UrlsDraco`) : explicite `DRACO_SHAARIT_URL` > hôte public de la requête (motif fermé étendu à `shaarit<SUFFIXE>.dracodev.net`) > dérivation depuis les adresses de l'agent connues > défaut officiel `https://shaarit.dracodev.net` (dev : `https://shaarit-dev.dracodev.net`). Aucune URL codée en dur dans le gabarit ; le JS vendangé résout aussi `urls.shaarit` (défaut officiel + dérivation `-dev`). `app/main.py` expose `draco_urls.shaarit` au gabarit.
- **Cache** : le bump 0.45.5 → 0.45.6 change aussi le `?v=` des CSS/JS de la barre.
## 0.45.5 — 2026-10-30 — Panneau d'alertes unifiées lisible en thème clair
- **Retour de Bruno (capture)** : en mode clair, le panneau « Alertes unifiées » s'affichait sombre et illisible — titre invisible, onglets vides. Cause : le CSS neutre `draco-alertes.css` (Fusion #69) attend des variables d'hôte (`--bg-primary`, `--text-dim`, `--text-tertiary`) que Kanbante ne définissait pas ; le panneau retombait sur ses replis sombres pendant que `--text` suivait le thème clair → texte sombre sur fond sombre.
- **Correctif côté hôte** (le fichier neutre partagé n'est pas modifié) : `.draco-alertes-box` reçoit ses variables branchées sur les jetons Kanbante — `--bg-primary: var(--surface)`, `--text-dim: var(--text-2)`, `--text-tertiary: var(--text-3)` — qui suivent déjà `html[data-theme]`. Le panneau est donc correct dans les deux thèmes ; `--text` et `--border` existaient déjà.
- **Tests** : `tests/test_alertes_theme.py` (variables branchées + fichier neutre inchangé). Vérification navigateur réel : styles calculés et captures relues en clair et en sombre.
## 0.45.4 — 2026-10-30 — Logo officiel en barre latérale, board clair, badge d'âge lisible
Retours de Bruno (captures du 2026-10-30), trois correctifs :
- **Logo de la barre latérale** : le pictogramme « 3 barres » est remplacé par le logo officiel de Kanbante — `static/img/logos/kanbante.png`, le même que l'onglet Kanbante de la barre d'apps commune. Le conteneur `.logo-mark` devient neutre (le PNG porte son propre fond vert) ; l'assertion du test d'identité suit.
- **Board en thème clair** : les colonnes et le panneau « Notes du projet » gardaient le fond sombre codé en dur (`rgba(20, 24, 34, .72)`) — surcharges `html[data-theme="light"]` en blanc translucide (0.85), comme la barre latérale et l'en-tête. Le thème sombre est inchangé.
- **Badge d'âge illisible en clair** (page En vol, Focus) : le texte jaune pâle `#fcd34d` sur fond jaune translucide passe en ambre foncé `#b45309` en thème clair ; même famille de contraste pour l'état « très vieux » et le compteur de colonne au-delà de sa limite WIP (`#dc2626`).
- **Vérifié en navigateur réel** (styles calculés + captures relues) : colonnes blanches et badge ambre lisible en clair, aucune régression en sombre. 367 tests verts (4 nouveaux, `tests/test_logo_et_contrastes.py`).
## 0.45.3 — 2026-10-29 — Barre commune v0.1.11 : marque monochrome, anneau rouge de l'avatar
- **Barre d'apps v0.1.11 vendangée (dracodev-commons)** : `draco-barre.css` remplacé verbatim (le JS Kanbante est déjà identique à celui de commons, inchangé en v0.1.11) ; fragment `_draco_bar.html` porté à la v0.1.11, le reste de l'intégration Kanbante (URL Jinja `draco_urls` / `agent_url`, `onAction` complet y compris profil/déconnexion, compteurs) est inchangé. Les surcharges Kanbante dans `style.css` ne sont pas touchées.
- **Marque monochrome** : la marque n'est plus le logo rouge — c'est l'étoile Spark monochrome (`static/img/brand-spark.png`, étoile blanche extraite du logo rouge, fond transparent, 26 px) : blanche sur barre foncée, noire sur barre claire via le filtre du CSS vendangé (`brightness(0)` par défaut, annulé en thème sombre). L'onglet agent et son entrée de menu gardent le logo rouge.
- **Anneau rouge de l'avatar** : le bouton avatar flottant bas-centre gagne une bordure rouge `#c00000` de 3 px (règle du CSS vendangé, `var(--agent)`).
- **Thème** : aucun correctif nécessaire — la barre Kanbante suit `html[data-theme]` (sombre par défaut, bascule mémorisée `kanbante-theme`) et `prefers-color-scheme`, tous deux couverts par le CSS vendangé ; vérifié, aucune surcharge `style.css` ajoutée.
- **Cache** : le bump 0.45.2 → 0.45.3 change aussi le `?v=` des CSS/JS.
## 0.45.2 — 2026-10-29 — Barre commune v0.1.10 : rouge Spark, marque logo, avatar flottant, Alertes icône seule
- **Barre d'apps v0.1.10 vendangée (dracodev-commons)** : `draco-barre.css` remplacé verbatim (le JS Kanbante est déjà identique à celui de commons v0.1.10, inchangé) ; fragment `_draco_bar.html` porté à la v0.1.10, le reste de l'intégration Kanbante (URL Jinja `draco_urls` / `agent_url`, `onAction` complet y compris profil/déconnexion, compteurs) est inchangé. Les surcharges Kanbante dans `style.css` ne sont pas touchées.
- **Rose → rouge Spark** : `--agent` passe de `#c2437e` à `#c00000` (rouge échantillonné dans le logo Spark) ; la marque ✦ devient le logo Spark (26 px) ; l'onglet agent passe à `--app #c00000`.
- **Bouton Spark rose supprimé** de la barre, remplacé par le bouton avatar flottant bas-centre (`static/img/avatar-spark.png`, cercle personnage, `data-act="spark"` → le panneau Spark de l'hôte via le `onAction` existant).
- **Alertes** : cloche seule, sans libellé — le bouton est moins large (pastille conservée).
- **Test** : l'assertion de la marque dans `tests/test_draco_barre.py` suit le nouveau CSS (`.draco-brand-mark img` au lieu de `font-size: 24px`, disparu en v0.1.10) ; aucun test n'affirmait le bouton Spark ni le libellé Alertes.
- **Cache** : le bump 0.45.1 → 0.45.2 change aussi le `?v=` des CSS/JS.
## 0.45.1 — 2026-10-29 — Barre commune : les 5 logos en place dans le menu et la barre
- **Barre d'apps v0.1.9 vendangée (dracodev-commons)** : `draco-barre.js` et `draco-barre.css` remplacés verbatim par la v0.1.9 (les fichiers vendangés précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale n'est perdue) ; fragment `_draco_bar.html` porté à la v0.1.9, le reste de l'intégration Kanbante (URL Jinja `draco_urls` / `agent_url`, `onAction` complet y compris profil/déconnexion, compteurs) est inchangé. Les surcharges Kanbante dans `style.css` ne sont pas touchées.
- **Icônes** : les 5 tuiles de la barre — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers servis depuis `static/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px) ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (la marque ✦, le bouton Spark et le rappel d'en-tête gardent leur ✦).
- **Cache** : le bump 0.45.0 → 0.45.1 change aussi le `?v=` des CSS/JS de la barre, donc les navigateurs ne garderont pas les anciennes icônes.
## 0.45.0 — 2026-10-29 — Mobile : tiroir, pied et menu compte
- **Tiroir mobile** : l'état « replié » mémorisé sur desktop ne rétrécit plus le tiroir (cause : `body.sidebar-collapsed .sidebar { width: 68px; }` gagnait par spécificité sur le 264 px mobile, libellés tronqués par overflow-x). Le tiroir fait 264 px, replié ou non ; le fond ne défile plus derrière lui ; le bouton de repli dans le tiroir le ferme ; un clic sur un lien de navigation le ferme aussi.
- **Pied de sidebar visible en mobile** : version, fraîcheur des données, thème et Sortir sont désormais accessibles dans le tiroir (avant : pied masqué, aucun moyen de sortir depuis un téléphone).
- **Menu compte draco** : Profil et Déconnexion fonctionnent enfin (ils étaient relayés par draco-barre.js mais jamais pris en charge par le onAction Kanbante — le menu se fermait sans rien faire). En mobile, c'était le seul accès au profil et à la déconnexion.
- **Menus draco** : largeur plafonnée à 100vw - 20 px pour ne jamais déborder d'un petit écran.
## 0.44.0 — 2026-10-29 — Ergonomie : tuile Projets et pied de sidebar
- **Tuile Projets** : en-tête groupé (gauche : organisation + santé, droite : date + étoile + menu) au lieu de 5 éléments éparpillés ; avancement global labellisé (« Avancement — X/Y terminées ») au lieu d'une barre anonyme ; jalon séparé par un filet, avec étiquette « Prochain jalon » et échéance distincte ; pied en 2 rangées — (1) compte d'issues + bouton Board, (2) répartition par colonne labellisée (« En cours 8 », « Terminé 59 », au lieu de chiffres nus 8 / 59) + prévision ; activité de la semaine en texte secondaire (« Cette semaine : … »).
- **Pied de sidebar** : 2 rangées séparées par un filet — (1) profil + bouton Sortir icône seule (⏻, aria-label, sans texte), (2) version + fraîcheur des données en pastille discrète (point vert + « à l'instant », infobulle expliquant les instantanés Gitea). En rail replié, la rangée méta est masquée, les icônes restent centrées.
## 0.43.0 — 2026-10-29 — Icônes monochromes sur tout le site (#59, suite)
- **Icônes colorées retirées de toutes les pages** : emoji décoratifs devant titres, boutons et liens supprimés (le texte porte le sens) ; icônes fonctionnelles passées en trait/monochrome — étoile ★/☆ (couleur texte, plus jaune), recherche en SVG, épingler déjà en SVG, thème ☀/☾ en symboles texte.
- **Statuts** : 🟢/🔴/✅/❌ remplacés par le mot (Actif, Désactivé…) ou par ●/✓ monochromes pour les états d'issue ; les pastilles de couleur sémantique (chip-ok, santé, colonnes) sont conservées, ce sont des informations, pas des décorations.
- **Forges** : 🐙/🍵 remplacés par les mots GitHub/Gitea.
- **Hors portée assumé** : barre Fusion partagée non touchée (ses icônes colorées sont l'identité des autres apps — ObsiGate, Flowdeck, TikTak, Spark) ; favicons et contenu utilisateur (titres/labels Gitea) inchangés ; symboles monochromes déjà en place conservés (⋯, ▾, ☰, ↗, ❮❯, ⏻).
## 0.42.0 — 2026-10-28 — Lot UX complet (#57 à #68)
- **#57 Jetons** : échelle fermée documentée (typo xs/sm/base/lg/xl, rayons sm/md/base/full, ombres, espacements) ; rayons pilule passés au jeton `--radius-full`.
- **#58 Boutons** : système primary uni (fini les dégradés), secondary, danger, icon 36px, états unifiés ; `.btn-save` et `.proj-primary` alignés sur le primary.
- **#59 Icônes** : emoji d'interface retirés des gabarits (cartes, tableaux, états vides, boutons, titres) ; épingler en SVG trait, recherche en SVG ; favicons et contenu utilisateur (titres/labels Gitea) inchangés.
- **#60 Accessibilité** : jeton `--accent-text` distinct (`#14b8a6` sombre, `#0f766e` clair, 5,47:1 sur blanc) pour tous les textes d'accent ; focus visible unifié.
- **#61 Typographie** : pile système assumée (Inter déclarée mais jamais chargée, pas de CDN tiers pour une app auto-hébergée) ; hiérarchie H1/H2 via `.page-title`.
- **#62 Pages** : Aujourd'hui et Focus reprennent l'en-tête `.page-head` / `.page-title` commun ; titre Aujourd'hui ramené à l'échelle xl.
- **#63 Navigation** : recherche en double supprimée de l'en-tête (palette Ctrl+K + recherche sidebar restent).
- **#64 Mobile** : cibles 44px sous 640px, en-tête et KPI compacts.
- **#65 États** : `.empty-state` / `.focus-empty` unifiés, classe `.skeleton` (pulsation, respect de `prefers-reduced-motion`).
- **#66 Formulaires** : classes `.field-error` / `.field-error-msg` (bordure rouge + message, jamais la couleur seule).
- **#67 Cartes** : titre clampé à 2 lignes, code discret, labels/chips en texte thème sur fond teinté `color-mix` (contraste garanti quelle que soit la couleur Gitea).
- **#68 Thème clair** : connexion et wallboard suivent le thème mémorisé (init avant rendu, comme `_header`) et ne restent plus sombres par défaut ; variantes claires ajoutées.
## 0.41.0 — 2026-10-28 — Identité Kanbante : sidebar charbon, accent vert, icônes ligne, bouton thème en Administration (#54, #55)
- **Sidebar (#54)** : fond charbon draco (`rgba(26, 28, 26, 0.96)`) à la place du bleu-noir `rgba(13, 16, 24)` oublié par le re-thème 0.39.0 — la barre latérale a désormais la même teinte que le reste du site, en sombre comme en clair (clair déjà blanc).
- **Accent vert (#54)** : le rose Spark cède la place au vert Kanbante — `--accent: #0d9488`, second ton `#14b8a6` (clair : `#0f766e`), anneaux, focus, dégradés et replis associés. La barre Fusion partagée n'est pas touchée (Spark rose, marques des autres apps).
- **Icônes (#54)** : les emoji de la barre latérale sont remplacés par des SVG fins en trait (stroke currentColor), style rail Spark, pour chaque entrée de navigation ; le logo Kanbante devient des colonnes kanban blanches sur fond vert sarcelle.
- **Bouton Dark/Light (#55)** : déplacé de l'en-tête applicatif vers la section Administration de la barre latérale (icône seule en rail replié, libellé explicite sinon). Comportement inchangé (bascule + choix mémorisé). Conséquence : seuls les administrateurs le voient, la section Administration leur étant réservée.
## 0.40.0 — 2026-10-28 — Navigation : section Administration + bouton Dark/Light (#51, #52)
- **Accordéon (#51)** : la section Administration ne fermait jamais — le JS posait `nav-closed-admin`, mais `style.css` ne définissait les règles de fermeture que pour les 4 autres sections. Les règles admin sont ajoutées (fermeture et rotation du chevron) : l'accordéon redevient exclusif.
- **Bouton Dark/Light (#52)** : bouton « ☀️ Mode clair / 🌙 Mode sombre » dans l'en-tête applicatif, qui bascule `data-theme` sur `<html>` et mémorise le choix (localStorage, appliqué avant le rendu par l'amorce de `_header.html`). Thème clair sur les jetons draco (fond #f2f2f0, surfaces blanches/grises, accent rose conservé, second ton assombri pour les liens), barre Fusion et panneau Spark qui suivent. Défaut inchangé : sombre. `nav.js` est désormais versionné (`?v=`), comme dashboard.js depuis la 0.38.1.
## 0.39.0 — 2026-10-28 — Thème du site : look agent-dev / barre Fusion (#49)
- **Re-thème complet** : `style.css` passe sur les jetons draco-barre (Console v7.2) — fond #101211, surfaces #1a1c1a / #242624 / #20221f, bordure #2e302c, textes #eceee9 / #a6aba2 / #8b9089, accent rose Spark #c2437e (second ton #e078a8). Fond plat, plus de halos ni de dégradés violet→bleu, y compris les valeurs de repli et l'écran de connexion.
- **Inchangé** : couleurs sémantiques (vert/jaune/rouge) et couleurs de colonnes choisies par l'utilisateur. Pas de mode clair : l'app force le sombre, comme la console de référence.
## 0.38.1 — 2026-10-28 — Page Projets : le correctif 0.38.0 atteint enfin le navigateur (#47)
- **Cache-busting (#47)** : `dashboard.html` charge désormais `style.css?v={{ version }}` et `dashboard.js?v={{ version }}`, comme les autres pages (admin, board, project) et la barre Fusion. Sans ça, le navigateur gardait l'ancien CSS (menu transparent, bouton de copie non stylé) et l'ancien JS (aucun gestionnaire de copie) alors que le HTML était déjà neuf — c'est pourquoi la 0.38.0 semblait ne rien changer, bouton présent mais inerte. Le code 0.38.0 lui-même (jetons `--menu-bg` opaques, gestionnaire `data-copy-url`) était correct dans le dépôt.
## 0.38.0 — 2026-10-28 — Page Projets : copie d'URL et refonte du menu ⋯
- **Copie d'URL (#44)** : chaque tuile projet (grille, y compris Épinglés) propose désormais « 🔗 Copier l'URL du projet » dans son menu ⋯ — l'URL Gitea du dépôt est copiée dans le presse-papiers, avec confirmation « URL du projet copiée » et repli (textarea + execCommand) quand l'API Clipboard n'est pas disponible.
- **Menu ⋯ (#45)** : refonte sur les jetons de la barre d'apps commune (draco-barre.css, prototype Console v7.2 / console Spark) — fond opaque, bordure, rayon 14 px, ombre portée, survol/focus visibles, séparateur avant l'action de copie. Corrige la cause du menu transparent : il utilisait `var(--surface-1)`, variable jamais définie.
- **Thèmes sombre et clair** : jetons clairs par défaut, variante sombre via `[data-theme="dark"]` sur `<html>` et repli `prefers-color-scheme`, comme la barre Fusion.
## 0.37.6 — 2026-10-28 — Corrections console/recherche : CORS console Spark
- **CORS recherche (PR #43)** : la recherche Kanbante autorise désormais la console Spark (agent prod+dev) en inter-origines — les pages console peuvent appeler la recherche de l'hôte sans erreur CORS/réseau.
## 0.37.5 — 2026-10-28 — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
-1
View File
@@ -10,7 +10,6 @@ ENV PYTHONUNBUFFERED=1 \
# first, copy app/ afterwards) the installed distribution was empty and
# the app only worked by accident of the working directory.
COPY pyproject.toml ./
COPY VERSION ./VERSION
COPY app/ ./app/
COPY static/ ./static/
RUN pip install --no-cache-dir .
-1
View File
@@ -1 +0,0 @@
0.46.4
-130
View File
@@ -9,10 +9,7 @@ be raised later without invalidating existing hashes.
import base64
import hashlib
import hmac
import re
import secrets
import sqlite3
import unicodedata
from app import models
@@ -69,130 +66,3 @@ def ensure_bootstrap_admin() -> dict | None:
display_name=settings.auth_user,
)
return models.get_user(user_id)
# ── Correspondance draco par utilisateur (HUB-16, 0.46.0) ────
# La correspondance identité draco ↔ compte Kanbante vit dans la
# colonne users.draco_sub (UNIQUE, présente depuis la 0.24.0) :
# models.get_user_by_draco(sub) est la résolution canonique.
# resolve_draco_user() est l'unique point d'entrée du pont SSO
# (app.main.draco_bridge) et du contrôle draco de /api/search :
#
# 1. correspondance existante → son compte (None si désactivé) ;
# 2. amorçage : s'il n'existe qu'UN seul compte et que c'est un
# admin actif sans liaison, le premier sub valide s'y lie —
# le compte d'amorçage (celui de Bruno, créé depuis
# l'environnement) garde ainsi son accès sans verrouillage ;
# 3. sinon, si provisionner=True : création JIT d'un compte
# propre à cette identité (voir provision_draco_user).
#
# Un sub inconnu ne désigne JAMAIS un compte existant non lié
# (ni l'opérateur d'environnement, ni un compte dont le nom
# ressemblerait au nom affiché draco) : la liaison d'un compte
# préexistant est un geste d'administrateur (/admin, voir
# docs/correspondance-draco.md) ou la première visite
# doublement connectée (session native + cookie draco).
def _slug_draco(valeur: str) -> str:
texte = unicodedata.normalize("NFKD", valeur or "")
texte = texte.encode("ascii", "ignore").decode("ascii").lower()
return re.sub(r"[^a-z0-9]+", "-", texte).strip("-")[:40].strip("-")
def _nom_utilisateur_draco(nom_affiche: str, sub: str) -> str:
"""Nom d'utilisateur libre dérivé des claims (jamais réutilisé).
Base : le nom affiché slugifié (« Alice Exemple » →
« alice-exemple ») ; repli « draco-<sub slugifié> ». Si le nom
est déjà pris — y compris par un compte préexistant NON lié,
qui ne doit pas être adopté — un suffixe -2, -3… est ajouté :
le JIT crée toujours un compte nouveau, distinct.
"""
base = _slug_draco(nom_affiche)
if len(base) < 3:
base = _slug_draco(f"draco-{sub}") or "draco"
candidat, n = base, 2
while models.get_user_by_username(candidat) is not None:
candidat = f"{base}-{n}"
n += 1
return candidat
def provision_draco_user(sub: str, nom_affiche: str = "") -> dict | None:
"""Création JIT du compte Kanbante d'une identité draco (HUB-16).
Rôle par défaut sûr : membre (« member », l'utilisateur
standard de Kanbante) — jamais admin. Le mot de passe local
est un secret aléatoire haché, jamais divulgué : l'accès
normal passe par draco ; un administrateur peut poser un mot
de passe local depuis /admin si besoin. Le compte rejoint
l'espace Principal comme membre, comme tout nouvel
utilisateur créé par l'administration.
"""
sub = str(sub or "")
if not sub:
return None
existant = models.get_user_by_draco(sub)
if existant is not None:
return existant
nom = (nom_affiche or "").strip()[:80]
for _tentative in range(5):
username = _nom_utilisateur_draco(nom, sub)
try:
user_id = models.create_user(
username,
hash_password(secrets.token_urlsafe(32)),
role="member",
display_name=nom,
draco_sub=sub,
)
break
except sqlite3.IntegrityError:
# Course sur draco_sub ou username : la correspondance
# gagnante fait foi ; sinon, réessayer un autre nom.
existant = models.get_user_by_draco(sub)
if existant is not None:
return existant
else:
return models.get_user_by_draco(sub)
espaces = models.list_workspaces()
if espaces:
models.set_member(espaces[0]["id"], user_id, "member")
models.log_admin(user_id, "draco.jit", f"{username} (sub {sub})")
return models.get_user(user_id)
def resolve_draco_user(claims: dict | None, provisionner: bool = False) -> dict | None:
"""Compte Kanbante désigné par des claims draco valides.
provisionner=False : correspondance et amorçage seulement
(surfaces qui ne doivent pas créer de compte, p. ex.
/api/search). provisionner=True : création JIT si aucune
correspondance n'existe (pont /auth/draco — la « première
connexion draco » du critère « un compte fonctionne
partout »). Un compte désactivé résout à None et n'est
jamais contourné par un JIT (la correspondance existe).
"""
sub = str((claims or {}).get("sub") or "")
if not sub:
return None
lie = models.get_user_by_draco(sub)
if lie is not None:
return lie if lie["active"] else None
if models.count_users() == 1:
seul = models.list_users()[0]
if (
seul["role"] == "admin"
and seul["active"]
and not seul.get("draco_sub")
):
# Compte d'amorçage : liaison au premier passage.
models.update_user(seul["id"], draco_sub=sub)
models.log_admin(seul["id"], "draco.link.amorcage", sub)
return models.get_user(seul["id"])
if provisionner:
cree = provision_draco_user(sub, str((claims or {}).get("name") or ""))
if cree is not None and cree["active"]:
return cree
return None
+5 -30
View File
@@ -130,10 +130,6 @@ Environment variables:
DRACO_TIKTAK_URL Public base URL of TikTak for the
common app bar. Default (official)
https://tiktak.dracodev.net.
DRACO_SHAARIT_URL Public base URL of ShaarIt for the
common app bar (barre v0.2.0).
Default (official)
https://shaarit.dracodev.net.
ObsiGate's bar link reuses DRACO_SSO_URL
(the SSO emitter IS ObsiGate).
Résolution d'une URL rendue à une
@@ -143,7 +139,7 @@ Environment variables:
ci-dessus (prioritaire, contrat
inchangé) ; 2) l'hôte public de la
requête, motif fermé
``(og|flowdeck|tiktak|kanbante|shaarit|agent)
``(og|flowdeck|tiktak|kanbante|agent)
<SUFFIXE>.dracodev.net`` (suffixe vide
en prod, ``-dev`` en dev) appliqué à
toutes les sœurs et à l'agent — jamais
@@ -156,7 +152,7 @@ Environment variables:
l'URL sœur devient
``https://<slug><SUFFIXE>.
dracodev.net`` (slugs : og, flowdeck,
tiktak, kanbante, shaarit, agent) ; hôte hors
tiktak, kanbante, agent) ; hôte hors
motif (autre domaine, LAN, IP) =
aucune dérivation, jamais de lien
deviné ; 4) le défaut officiel (prod).
@@ -181,8 +177,7 @@ from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent
_version_file = next((Path(p) for p in ("/VERSION", BASE_DIR / "VERSION") if Path(p).exists()), None)
APP_VERSION = _version_file.read_text().strip() if _version_file else "inconnue"
APP_VERSION = "0.37.5"
def _env_bool(name: str, default: bool) -> bool:
@@ -237,7 +232,7 @@ def _env_wip_limits(name: str) -> dict[str, int]:
# du service (élargie en 0.32.2) ; 4) défaut officiel.
_MOTIF_HOTE_AGENT = re.compile(r"agent([a-z0-9-]*)\.dracodev\.net")
_MOTIF_HOTE_PUBLIC = re.compile(
r"(?:og|flowdeck|tiktak|kanbante|shaarit|agent|hub)([a-z0-9-]*)\.dracodev\.net"
r"(?:og|flowdeck|tiktak|kanbante|agent)([a-z0-9-]*)\.dracodev\.net"
)
@@ -270,7 +265,7 @@ def _suffixe_environnement_agent(url_agent: str) -> str | None:
def suffixe_environnement_requete(hote_requete: str | None) -> str | None:
"""Suffixe lu sur l'hôte public de la requête entrante.
Motif fermé ``^(og|flowdeck|tiktak|kanbante|shaarit|agent)<SUFFIXE>
Motif fermé ``^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>
\\.dracodev\\.net$`` (suffixe optionnel : vide en prod,
``-dev`` en dev) ; un port éventuel est toléré et retiré.
Hors motif (LAN, IP, autre domaine) : ``None`` — l'appelant
@@ -311,8 +306,6 @@ class UrlsDraco:
sso_url: str # ObsiGate (lien de barre ; explicite = DRACO_SSO_URL)
flowdeck_url: str
tiktak_url: str
shaarit_url: str # ShaarIt (barre v0.2.0 ; explicite = DRACO_SHAARIT_URL)
hub_url: str # Hub (barre v0.3.0 ; explicite = DRACO_HUB_URL)
public_url: str # Kanbante elle-même (KANBANTE_PUBLIC_URL)
agent_url: str # base du service d'agents (panneau + CSP)
@@ -351,10 +344,6 @@ def resoudre_urls_draco(
"DRACO_FLOWDECK_URL", "flowdeck", base.flowdeck_url
),
tiktak_url=resoudre("DRACO_TIKTAK_URL", "tiktak", base.tiktak_url),
shaarit_url=resoudre(
"DRACO_SHAARIT_URL", "shaarit", base.shaarit_url
),
hub_url=resoudre("DRACO_HUB_URL", "hub", base.hub_url),
public_url=resoudre(
"KANBANTE_PUBLIC_URL", "kanbante", base.public_url
),
@@ -404,8 +393,6 @@ class Settings:
public_url: str = "https://kanbante.dracodev.net"
flowdeck_url: str = "https://flowdeck.dracodev.net"
tiktak_url: str = "https://tiktak.dracodev.net"
shaarit_url: str = "https://shaarit.dracodev.net"
hub_url: str = "https://hub.dracodev.net"
agent_url: str = "https://agent.dracodev.net"
archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
@@ -510,18 +497,6 @@ def _env_settings() -> Settings:
"https://tiktak.dracodev.net",
suffixe_agent,
),
shaarit_url=_url_soeur_draco(
"DRACO_SHAARIT_URL",
"shaarit",
"https://shaarit.dracodev.net",
suffixe_agent,
),
hub_url=_url_soeur_draco(
"DRACO_HUB_URL",
"hub",
"https://hub.dracodev.net",
suffixe_agent,
),
agent_url=agent_url,
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
agent_evenements_url=agent_evenements_url,
+3 -6
View File
@@ -9,12 +9,9 @@ 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. Depuis la 0.46.0 (HUB-16), la
correspondance est PAR UTILISATEUR : le ``sub`` des claims désigne
le compte Kanbante lié dans ``users.draco_sub`` (résolution et
création JIT : app.accounts.resolve_draco_user) — un jeton valide
ne désigne plus « l'opérateur » par hypothèse. Ce module ne fait
que valider le jeton ; il ne connaît aucun compte. Sans secret
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.
"""
-120
View File
@@ -1,120 +0,0 @@
"""Rôles par application — consommation du registre du Hub (HUB-19).
Portage Python du pilote ShaarIt 0.7.0 (``src/hub_roles.rs``),
contrat D3(a) du plan Hub v2 : à l'ouverture de session draco,
Kanbante demande au Hub le niveau du porteur du cookie pour
l'application ``kanbante`` — ``GET {hub}/api/roles/kanbante``,
en-tête Cookie complet de la requête relayé tel quel,
``Accept: application/json``, délai 2 s → ``{"app", "sub",
"role", "explicite", "ttl_secondes": 60}``, rôle ∈ {``aucun``,
``utilisateur``, ``administrateur``}.
Cache mémoire par ``sub``, TTL 60 s, sur le patron du cache de
révocations de ``app.draco`` : l'horodatage est posé **avant**
l'appel — un échec ne déclenche pas de rafale et l'ancien cache
reste en vigueur (il resservira aux appels suivants pendant le
TTL posé par la tentative). L'URL du Hub est résolue comme celle
de la barre commune (``app.config.resoudre_urls_draco`` :
variable explicite ``DRACO_HUB_URL`` > hôte public de la requête
> dérivation par l'hôte sœur de l'agent > défaut officiel) —
jamais d'URL dev figée.
**Repli obligatoire** : Hub injoignable, en erreur ou réponse
illisible → ``None`` (rôle **inconnu**), et l'appelant garde son
comportement actuel. Le registre ne crée jamais un verrouillage
nouveau.
"""
from __future__ import annotations
import logging
import threading
import time
import httpx
from app.config import resoudre_urls_draco
logger = logging.getLogger(__name__)
#: Identifiant de Kanbante dans le registre des applications du Hub.
APP = "kanbante"
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub
#: et sur le cache de révocations draco.
CACHE_TTL = 60.0 # secondes
ROLES_CONNUS = ("aucun", "utilisateur", "administrateur")
_verrou = threading.Lock()
_cache: dict[str, str] = {}
_cache_fetched_at: float = 0.0
def _reset_cache() -> None:
"""Vide le cache (tests, ou redémarrage de processus)."""
global _cache_fetched_at
with _verrou:
_cache.clear()
_cache_fetched_at = 0.0
def _consulter(hub_url: str, cookie: str) -> str | None:
"""Un appel au registre ; rôle connu ou ``None`` (inconnu)."""
url = f"{hub_url.rstrip('/')}/api/roles/{APP}"
try:
reponse = httpx.get(
url,
headers={"Cookie": cookie, "Accept": "application/json"},
timeout=2.0,
)
except Exception as exc: # Hub injoignable : repli, jamais un blocage
logger.debug("Registre des rôles du Hub injoignable (%s) : %s", url, exc)
return None
if getattr(reponse, "status_code", None) != 200:
logger.debug(
"Registre des rôles du Hub en erreur (%s) : statut %s",
url, getattr(reponse, "status_code", "?"),
)
return None
try:
donnees = reponse.json()
except Exception:
return None
if not isinstance(donnees, dict):
return None
role = donnees.get("role")
return role if role in ROLES_CONNUS else None
def role_pour(cookie: str, sub: str, hote: str | None = None) -> str | None:
"""Niveau connu pour ce ``sub`` : un rôle ou ``None`` (inconnu).
``cookie`` est l'en-tête Cookie complet de la requête, relayé
tel quel au Hub ; ``hote`` est l'hôte public de la requête,
pour résoudre l'URL du Hub comme la barre commune.
"""
global _cache_fetched_at
sub = str(sub or "")
if not sub:
return None
now = time.monotonic()
with _verrou:
if now - _cache_fetched_at < CACHE_TTL and sub in _cache:
return _cache[sub]
# Horodatage posé avant l'appel (patron révocations) :
# l'ancien cache est conservé tel quel pendant l'appel.
_cache_fetched_at = now
hub_url = resoudre_urls_draco(hote).hub_url
resultat = _consulter(hub_url, cookie)
if resultat is not None:
with _verrou:
_cache[sub] = resultat
_cache_fetched_at = time.monotonic()
else:
logger.debug(
"Registre des rôles du Hub indisponible (%s/api/roles/%s)"
" — comportement actuel conservé",
hub_url, APP,
)
return resultat
+57 -205
View File
@@ -263,48 +263,23 @@ def _session_secret() -> bytes:
def _make_session(user: str, via_draco: bool = False) -> str:
"""Signed session cookie (0.22.0): user.expiry.hmac.
HUB-19 (0.46.2) : une session **établie par le pont draco**
porte un segment ``draco`` couvert par la signature
(``user.expiry.draco.hmac``) — c'est ce marqueur infalsifiable
qui soumet ensuite ``/admin`` au registre des rôles du Hub
(voir ``_require_admin``). Les sessions natives gardent le
format historique, et les deux formats restent lisibles.
"""
def _make_session(user: str) -> str:
"""Signed session cookie (0.22.0): user.expiry.hmac."""
exp = int(time.time()) + SESSION_DAYS * 86400
charge = f"{user}.{exp}.draco" if via_draco else f"{user}.{exp}"
sig = hmac.new(
_session_secret(), charge.encode("utf-8"), hashlib.sha256
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
).hexdigest()
return f"{charge}.{sig}"
return f"{user}.{exp}.{sig}"
def _session_info(request: Request) -> tuple[str, bool] | None:
"""(utilisateur, établie par draco) de la session, ou None.
Vérifie signature et expiration, dans les deux formats
(natif historique et marqué draco de la 0.46.2).
"""
def _session_user(request: Request) -> str | None:
raw = request.cookies.get(SESSION_COOKIE, "")
morceaux = raw.split(".")
if len(morceaux) < 3:
return None
sig = morceaux[-1]
via_draco = len(morceaux) >= 4 and morceaux[-2] == "draco"
if via_draco:
exp = morceaux[-3]
user = ".".join(morceaux[:-3])
charge = f"{user}.{exp}.draco"
else:
exp = morceaux[-2]
user = ".".join(morceaux[:-2])
charge = f"{user}.{exp}"
user, _, rest = raw.partition(".")
exp, _, sig = rest.partition(".")
if not user or not exp or not sig:
return None
expected = hmac.new(
_session_secret(), charge.encode("utf-8"), hashlib.sha256
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
).hexdigest()
if not secrets.compare_digest(sig, expected):
return None
@@ -313,14 +288,6 @@ def _session_info(request: Request) -> tuple[str, bool] | None:
return None
except ValueError:
return None
return user, via_draco
def _session_user(request: Request) -> str | None:
info = _session_info(request)
if info is None:
return None
user = info[0]
# 0.24.0 : la session peut appartenir à n'importe quel
# compte de la base (résolu ensuite par _current_user) ou,
# en mode env pur, à l'opérateur d'environnement.
@@ -332,12 +299,6 @@ def _session_user(request: Request) -> str | None:
return None
def _session_via_draco(request: Request) -> bool:
"""Vrai si la session présentée a été établie par le pont draco."""
info = _session_info(request)
return bool(info and info[1] and _session_user(request) is not None)
def _basic_ok(request: Request) -> bool:
settings = get_settings()
header = request.headers.get("authorization", "")
@@ -430,24 +391,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
and _bearer_user(request) is not None:
return await call_next(request)
# Recherche partout (#68) : /api/search accepte aussi le jeton
# SSO draco_session — depuis la 0.46.0 (HUB-16), par
# utilisateur : le sub doit désigner un compte Kanbante
# (correspondance existante ou compte d'amorçage lié au
# passage, voir app.accounts.resolve_draco_user) ; un jeton
# valide sans correspondance ne désigne plus l'opérateur et
# ne suffit plus. Aucun JIT ici : la création du compte a
# lieu à la première connexion par le pont /auth/draco.
# Les preflights OPTIONS ne portent jamais de cookies : ils
# sont répondus par RechercheCorsMiddleware avant d'arriver ici.
# SSO draco_session (mono-utilisateur : un jeton valide désigne
# l'opérateur), comme les API de recherche des sœurs. Les
# preflights OPTIONS ne portent jamais de cookies : ils sont
# répondus par RechercheCorsMiddleware avant d'arriver ici.
if path == "/api/search":
try:
from app import accounts as accounts_service
from app import draco
claims = draco.claims_for(request)
if claims is not None and (
accounts_service.resolve_draco_user(claims) is not None
):
if draco.claims_for(request) is not None:
return await call_next(request)
except Exception:
pass
@@ -619,87 +571,67 @@ def draco_bridge(request: Request, next: str = "/"):
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
Point de retour de ``GET /sso/authorize`` de l'émettrice
(ObsiGate). Depuis la 0.46.0 (HUB-16), la correspondance est
PAR UTILISATEUR et l'hypothèse mono-utilisateur a disparu —
résolution centralisée dans app.accounts.resolve_draco_user :
- session native déjà ouverte : le ``sub`` libre est lié à
ce compte (première visite doublement connectée, inchangé) ;
- ``sub`` lié à un compte : sa session est ouverte ;
- compte d'amorçage (unique compte, admin, sans liaison) :
liaison au premier passage, puis sa session — le compte
existant de l'opérateur garde son accès ;
- sinon : création JIT d'un compte propre à cette identité
(rôle membre, mot de passe local aléatoire non divulgué)
et ouverture de sa session.
Un ``sub`` inconnu n'ouvre plus jamais la session de
l'opérateur d'environnement ni celle d'un compte non lié.
(ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR :
si le ``sub`` draco est lié à un compte, sa session est
créée ; si une session native est déjà ouverte, le ``sub``
est lié à ce compte (première visite doublement connectée) ;
sans compte lié, retour au login — la connexion native qui
suit crée le lien (voir login_submit). Sans utilisateurs en
base, comportement historique : session de l'opérateur.
Inerte sans ``DRACO_SESSION_SECRET``.
HUB-19 (0.46.2, contrat D3(a)) : le registre des rôles du Hub
est consulté **en premier** — avant la liaison à une session
native, avant la résolution de la correspondance et avant
toute création JIT. Rôle connu ``aucun`` → le pont refuse :
aucune session Kanbante n'est ouverte par draco pour ce
``sub`` et aucun compte n'est créé. Rôle inconnu (Hub
injoignable) → comportement actuel, inchangé. Les autres
accès ne passent pas par ce pont et ne sont pas touchés :
session native préexistante, connexion native (mot de
passe), Basic d'environnement, jetons ``kbt_``.
"""
from app import accounts as accounts_service
from app import draco
from app import hub_roles
settings = get_settings()
claims = draco.claims_for(request)
if not settings.auth_enabled or claims is None:
return RedirectResponse("/login", status_code=303)
sub = str(claims.get("sub") or "")
if not sub:
return RedirectResponse("/login", status_code=303)
# HUB-19 : consultation du registre AVANT toute résolution
# ou création (resolve_draco_user peut créer le compte JIT :
# un sub refusé ne doit jamais en recevoir un).
role_hub = hub_roles.role_pour(
request.headers.get("cookie", ""), sub,
hote=request.headers.get("host"),
)
if role_hub == "aucun":
return render(
request, "error.html", status_code=403,
message=(
"Accès refusé : le registre des rôles de la suite "
"(Hub Dracodev) attribue le niveau « aucun » à "
"votre compte pour Kanbante — l'ouverture de "
"session par draco est refusée et aucun compte "
"Kanbante n'a été créé. L'administrateur de la "
"suite peut modifier ce niveau dans la matrice "
"des rôles du Hub. Vos autres accès à Kanbante "
"(connexion par mot de passe, session déjà "
"ouverte) ne sont pas touchés."
),
)
sub = claims.get("sub")
# Déjà connecté nativement : lier le sub à ce compte.
current = _current_user(request)
if current is not None and current.get("id") is not None:
if not current.get("draco_sub"):
if sub and not current.get("draco_sub"):
if models.get_user_by_draco(sub) is None:
models.update_user(current["id"], draco_sub=sub)
models.log_admin(current["id"], "draco.link", sub)
models.log_admin(current["id"], "draco.link", str(sub))
return RedirectResponse(_safe_next(next), status_code=303)
# Correspondance, amorçage ou création JIT : ouvrir la
# session du compte que ce sub désigne — et seulement lui.
# La session est marquée « établie par draco » (segment signé,
# voir _make_session) : c'est elle, et elle seule, que le
# garde de /admin soumettra au registre du Hub.
user = accounts_service.resolve_draco_user(claims, provisionner=True)
if user is not None:
# Compte lié à ce sub : ouvrir sa session.
linked = models.get_user_by_draco(sub) if sub else None
if linked is not None and linked["active"]:
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(user["username"], via_draco=True),
_make_session(linked["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
if models.count_users() == 1:
# Mono-utilisateur (cas historique) : le premier sub
# valide se lie au compte unique et ouvre sa session —
# c'est la sémantique livrée en 0.23.0, conservée tant
# qu'il n'y a qu'un seul compte.
sole = models.list_users()[0]
if sole["active"]:
if sub and not sole.get("draco_sub"):
if models.get_user_by_draco(sub) is None:
models.update_user(sole["id"], draco_sub=sub)
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(sole["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
if models.count_users() == 0:
# Mode env pur (aucun compte) : session de l'opérateur.
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(settings.auth_user),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
@@ -1082,28 +1014,6 @@ def _require_admin(request: Request) -> dict | None:
user = _current_user(request)
if user is None or user.get("role") != "admin":
return None
# HUB-19 (0.46.2) : pour une session **établie par le pont
# draco** (marqueur signé, voir _make_session), si le rôle du
# registre du Hub est connu, il faut AUSSI « administrateur »
# dans le registre — en plus du rôle local ci-dessus. Rôle
# inconnu (Hub injoignable) ou session native (mot de passe,
# Basic, jeton) : règles locales actuelles, inchangées. Ce
# garde couvre /admin et toutes ses actions (même fonction).
if _session_via_draco(request):
from app import draco
from app import hub_roles
sub = str(user.get("draco_sub") or "")
if not sub:
claims = draco.claims_for(request)
sub = str((claims or {}).get("sub") or "")
if sub:
role_hub = hub_roles.role_pour(
request.headers.get("cookie", ""), sub,
hote=request.headers.get("host"),
)
if role_hub is not None and role_hub != "administrateur":
return None
return user
@@ -1254,62 +1164,6 @@ async def admin_reset_user(
return RedirectResponse("/admin?msg=Mot+de+passe+r%C3%A9initialis%C3%A9", status_code=303)
@app.post("/admin/users/{user_id}/draco")
async def admin_draco_user(
request: Request,
user_id: int,
draco_sub: str = Form(""),
csrf_token: str = Form(""),
):
"""Liaison draco par l'administrateur (HUB-16, 0.46.0).
C'est le mécanisme de liaison des comptes Kanbante
préexistants : l'administrateur saisit le ``sub`` draco du
compte (colonne Draco de /admin) ; un champ vide délie.
Un ``sub`` déjà lié à un autre compte est refusé (la
correspondance est biunivoque : users.draco_sub est UNIQUE).
Contrairement au rôle et à l'activation, le geste est permis
sur son propre compte : c'est la voie manuelle qui établit la
correspondance du compte d'amorçage si le premier passage ne
l'a pas fait. Le registre matriciel côté Hub (HUB-19) lira
cette même correspondance ; ici, seul le côté Kanbante est
requis. Procédure : docs/correspondance-draco.md.
"""
from urllib.parse import quote as _q
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = models.get_user(user_id)
if target is None:
return RedirectResponse("/admin", status_code=303)
sub = draco_sub.strip()
if sub:
autre = models.get_user_by_draco(sub)
if autre is not None and autre["id"] != user_id:
return RedirectResponse(
"/admin?err="
+ _q("Identité draco déjà liée à un autre compte"),
status_code=303,
)
models.update_user(user_id, draco_sub=sub)
models.log_admin(
admin.get("id"), "draco.link.admin",
f"{target['username']} : {sub}",
)
return RedirectResponse(
"/admin?msg=" + _q("Liaison draco enregistrée"),
status_code=303,
)
models.update_user(user_id, draco_sub=None)
models.log_admin(admin.get("id"), "draco.unlink.admin", target["username"])
return RedirectResponse(
"/admin?msg=" + _q("Liaison draco retirée"), status_code=303
)
@app.get("/admin/connections")
async def admin_connections(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)
@@ -2396,8 +2250,6 @@ def render(
"flowdeck": _urls.flowdeck_url,
"tiktak": _urls.tiktak_url,
"kanbante": _urls.public_url,
"shaarit": _urls.shaarit_url,
"hub": _urls.hub_url,
},
)
context.setdefault("agent_url", _urls.agent_url)
+2 -4
View File
@@ -1012,14 +1012,12 @@ def create_user(
role: str = "member",
display_name: str = "",
must_change_password: int = 0,
draco_sub: str | None = None,
) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO users (username, password_hash, role, display_name, "
"must_change_password, draco_sub) VALUES (?, ?, ?, ?, ?, ?)",
(username, password_hash, role, display_name,
must_change_password, draco_sub),
"must_change_password) VALUES (?, ?, ?, ?, ?)",
(username, password_hash, role, display_name, must_change_password),
)
return int(cur.lastrowid)
+9 -11
View File
@@ -10,10 +10,9 @@ testables sans base ni serveur.
tous projets du workspace) et la regroupe par dépôt.
- La CORS bornée n'autorise que ``GET /api/search``, et seulement
pour les origines sœurs officielles **prod ET dev, listées
explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus les
origines de la console Spark** (``AGENT_ORIGINS`` : agent prod ET
dev — fédération de la recherche depuis la console, 2026-10-28),
**plus** les
explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus** les
origines du service d'agents (``AGENT_ORIGINS`` : console Spark
prod ET dev — fédération console, défaut 2), **plus** les
origines résolues par ``app.config`` — jamais ``*``. Correctif
2026-10-28 (fusion #68/#69, matrice constatée par Bruno) :
l'ancienne allowlist ne contenait que l'environnement résolu
@@ -38,7 +37,6 @@ PROD_ORIGINS = frozenset({
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://shaarit.dracodev.net",
})
DEV_ORIGINS = frozenset({
@@ -46,17 +44,18 @@ DEV_ORIGINS = frozenset({
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://shaarit-dev.dracodev.net",
})
# Origines du service d'agents (console Spark) : la page de
# recherche de la console appelle /api/search depuis le navigateur
# avec le cookie SSO — sans ces deux origines, le navigateur refuse
# la lecture (fédération console, défaut 2). Jamais "*".
AGENT_ORIGINS = frozenset({
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
})
# Clés du dictionnaire rendu par render() (app/main.py).
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
def is_dev_host(hostname: str | None) -> bool:
@@ -105,10 +104,9 @@ def sister_origins(
"flowdeck": urls.flowdeck_url,
"tiktak": urls.tiktak_url,
"kanbante": urls.public_url,
"shaarit": urls.shaarit_url,
}
except Exception:
return PROD_ORIGINS | DEV_ORIGINS
return PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
origins = set()
for key in _SOEUR_KEYS:
origin = origin_of((resolved or {}).get(key))
+4 -4
View File
@@ -13,14 +13,14 @@
{% if issue.labels %}
<div class="card-labels">
{% for label in issue.labels %}
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
{% if issue._code %}
<div class="card-code">⑂{% if issue._code.branches %} {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
<div class="card-code">⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
@@ -30,8 +30,8 @@
{% set age = ages.get(issue.number) %}
{% if age and age.days >= aging_days %}
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
title="Dans cette colonne depuis le {{ age.since[:10] }}">{{ age.days }} j</span>
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
{% endif %}
<button type="button" class="pin-btn btn-icon" title="Épingler au Focus du jour (/today)" aria-label="Épingler au Focus du jour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4h6l-1 8 3 3H7l3-3z"/><path d="M12 15v5"/><path d="M9 20h6"/></svg></button>
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
</div>
</div>
+18 -94
View File
@@ -1,51 +1,16 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet vendangé depuis Projets/dracodev-commons v0.3.0
(ui/barre-apps/, branche hub-menu-compte-v0-3-0 de la PR commons
#9 — HUB-1, fusion #115) : v0.3.0 = menu compte à liens Hub
(Télécharger, À Propos, Aides, Profil-lien ; base
draco_urls.hub en urls.hub ci-dessous) et branche « profil »
de onAction RETIRÉE (voir le commentaire dans la configuration —
la page /profile de Kanbante existe toujours, elle n'est plus
la cible du menu compte). Structure du fragment
commons portée ici ; seule adaptation locale (0.32.0, dev/prod)
= les URL des apps et l'adresse du service d'agents sont
INJECTÉES par l'hôte depuis app.config (draco_urls / agent_url,
défauts = noms officiels) — aucun nom public codé en dur dans
ce gabarit ni dans draco-panneau.js. Le CSS et le JS, eux, sont
vendangés VERBATIM (static/css/draco-barre.css,
static/js/draco-barre.js — voir CHANGELOG 0.45.10).
v0.2.0 (phase 3 ShaarIt) : nouvel onglet ShaarIt
(data-app="shaarit", draco_urls.shaarit), APRÈS Kanbante et
AVANT l'onglet agent ; couleur émeraude #059669, logo
static/img/logos/shaarit.svg (copie verbatim de commons) ;
GARDE data-theme : le CSS v0.2.0 conditionne les blocs
prefers-color-scheme à html:not([data-theme]) — la barre ne
force plus le sombre système quand l'app est en thème clair
explicite (voir CHANGELOG 0.45.6, en tête).
Snippet vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : structure inchangée ; seule adaptation locale
(0.32.0, dev/prod) = les URL des apps et l'adresse du service
d'agents sont INJECTÉES par l'hôte depuis app.config
(draco_urls / agent_url, défauts = noms officiels) — aucun nom
public codé en dur dans ce gabarit ni dans draco-panneau.js.
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
(data-app="agent", agent_url), après Kanbante ; l'entrée
« Console Spark » du menu de la marque est conservée.
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).
Icônes logos v0.1.9 (2026-10-29) : les tuiles sont les logos
fournis, tels quels, servis depuis /static/img/logos/ (copie de
ui/barre-apps/logos/ de dracodev-commons) — jamais de glyphe
texte, jamais de redessin ; l'entrée « Console Spark » du menu
clone l'icône de l'onglet agent (draco-barre.js).
Portage v0.1.10 (2026-10-29) : marque = logo Spark 26 px ;
--agent rouge Spark #c00000 ; bouton Spark rose supprimé,
remplacé par l'avatar flottant bas-centre (data-act="spark"
→ panneau Spark de l'hôte) ; Alertes = cloche seule.
Portage v0.1.11 (2026-10-29) : marque = étoile Spark monochrome
(static/img/brand-spark.png, blanche sur barre foncée, noire
sur barre claire via le filtre du CSS vendangé) ; l'onglet
agent et son entrée de menu gardent le logo rouge ; anneau
rouge #c00000 autour de l'avatar flottant (CSS vendangé).
Thème : la barre suit html[data-theme] (sombre par défaut,
bascule mémorisée kanbante-theme) et prefers-color-scheme —
tous deux couverts par le CSS vendangé, aucune surcharge
style.css nécessaire. #}
wallboard ni l'écran de connexion, volontaires). #}
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ version }}">
@@ -54,33 +19,29 @@
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d&#39;application" title="Changer d&#39;application">
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="dracodev" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
<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="{{ draco_urls.obsigate }}/">
<span class="draco-apptab-ico" style="--app:#7a5af5"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
<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="{{ draco_urls.flowdeck }}/">
<span class="draco-apptab-ico" style="--app:#2f6fed"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
<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="{{ draco_urls.tiktak }}/">
<span class="draco-apptab-ico" style="--app:#e8762c"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
<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="{{ draco_urls.kanbante }}/">
<span class="draco-apptab-ico" style="--app:#0d9488"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
<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>
<a class="draco-apptab" data-app="shaarit" href="{{ draco_urls.shaarit }}/">
<span class="draco-apptab-ico" style="--app:#059669"><img src="/static/img/logos/shaarit.svg" alt="ShaarIt" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>ShaarIt</strong><small>Bookmarks</small></span>
</a>
<a class="draco-apptab" data-app="agent" href="{{ agent_url }}/">
<span class="draco-apptab-ico" style="--app:#c00000"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
@@ -101,12 +62,16 @@
</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-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="Opérateur Kanbante">{{ draco_avatar }}</button>
</div>
</header>
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
<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).
@@ -118,21 +83,6 @@
recherche partout : page /recherche (Fusion #68). */
window.DRACO_BAR_CONFIG = {
current: "kanbante",
/* Barre v0.3.0 (HUB-1) : base du Hub résolue par l'hôte
(draco_urls.hub — DRACO_HUB_URL explicite, hôte de la
requête, dérivation par l'agent, défaut officiel) ;
le Hub n'a pas d'onglet, urls.hub alimente les liens
du menu compte. */
/* Barre v0.6.0 (fusion #142) : JS/CSS re-vendangés
verbatim — le menu compte gagne l'entrée
« Administration » (lien Hub <hub>/admin) à visibilité
conditionnelle. Kanbante ne passe PAS le drapeau admin :
il ne connaît que le rôle du registre du Hub pour sa
propre app (consulté avant la création JIT), pas le
rôle global de suite (rôle du compte chez ObsiGate) —
à défaut de certitude, l'entrée reste masquée ici,
comme le contrat l'exige. */
urls: { hub: "{{ draco_urls.hub }}/" },
/* v0.1.6 : user = initiale auth_user ou K (draco_avatar). Sans
cfg.user, le nouveau JS dériverait l'initiale du title
("Opérateur Kanbante" → "O"), qui est un libellé de rôle et
@@ -153,32 +103,6 @@
};
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; }
/* Menu compte — barre v0.3.0 (HUB-1) : « Profil » n'est
PLUS relayé par la barre : c'est un vrai lien vers
<hub>/profil (un seul Profil, celui du Hub — décision
du plan Hub). L'ancienne branche onAction("profil")
qui menait à la page /profile propre à Kanbante est
donc RETIRÉE : la page /profile elle-même continue
d'exister (jetons API, réglages du compte Kanbante),
mais elle n'est plus la cible du menu compte de la
barre. « Déconnexion » : voir la branche ci-dessous. */
if (act === "deconnexion") {
/* Fusion #141 : la déconnexion n'est plus seulement
locale. Détruire d'abord la session Kanbante
(GET /logout), puis naviguer vers la déconnexion
globale de l'émettrice (ObsiGate, GET /sso/logout)
avec retour ici — le jti draco y est révoqué et le
cookie effacé sur le domaine parent. Avant ce
correctif, le seul /logout laissait la session SSO
vivante : le pont /auth/draco rouvrait une session
Kanbante sans mot de passe. */
var retourKb = encodeURIComponent(location.origin + "/");
var cibleKb = "{{ draco_urls.obsigate }}/sso/logout?return_to=" + retourKb;
fetch("/logout", { credentials: "same-origin" })
.catch(function () { })
.finally(function () { location.href = cibleKb; });
return;
}
if (act === "palette") { location.href = "/recherche"; return; }
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { location.href = "/activity"; } return; }
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). */
+37 -64
View File
@@ -1,15 +1,3 @@
<script>
/* Thème (#52) : appliqué avant le rendu de la barre, pour
éviter le flash sombre quand l'utilisateur a choisi le
clair. Défaut = sombre (les gabarits portent
data-theme="dark") ; seul un choix mémorisé bascule. */
try {
var _t = localStorage.getItem('kanbante-theme');
if (_t === 'light' || _t === 'dark') {
document.documentElement.setAttribute('data-theme', _t);
}
} catch (e) { /* mode privé : le thème ne survit qu'à la page */ }
</script>
{% include "_draco_bar.html" %}
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
@@ -21,38 +9,38 @@
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
{% set nav_groups = [
("principal", "Principal", [
("home", "/", "", "Projets"),
("focus", "/focus", "", "Focus"),
("today", "/today", "", "Aujourd'hui"),
("my", "/my", "", "Mes issues"),
("home", "/", "🏠", "Projets"),
("focus", "/focus", "🎯", "Focus"),
("today", "/today", "🌅", "Aujourd'hui"),
("my", "/my", "👤", "Mes issues"),
]),
("rituels", "Rituels", [
("briefing", "/briefing", "", "Briefing"),
("standup", "/standup", "", "Standup"),
("digest", "/digest", "", "Digest"),
("briefing", "/briefing", "📊", "Briefing"),
("standup", "/standup", "🗣️", "Standup"),
("digest", "/digest", "🧠", "Digest"),
]),
("pilotage", "Pilotage", [
("inflight", "/inflight", "", "En vol"),
("forecast", "/forecast", "", "Prévisions"),
("health", "/health", "", "Santé"),
("rules", "/rules", "", "Règles"),
("activity", "/activity", "", "Activité"),
("inflight", "/inflight", "🛩️", "En vol"),
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
("activity", "/activity", "📰", "Activité"),
]),
("outils", "Outils", [
("search", "/search", "", "Recherche"),
("capture", "/capture", "", "Capture"),
("archives", "/archives", "", "Archives"),
("wall", "/wall", "", "Wallboard"),
("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_columns", "/admin/columns", "", "Colonnes"),
("admin_settings", "/admin/settings", "", "Paramètres"),
("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]),
] %}
{% endif %}
@@ -90,10 +78,10 @@
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
<div class="side-head">
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
<span class="logo-mark"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="34" height="34"></span>
<span class="logo-mark">📋</span>
<span class="side-label gradient-text">Kanbante</span>
</a>
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg></button>
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
</div>
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
@@ -107,9 +95,6 @@
</select>
</form>
{% endif %}
{# Icônes de navigation (#54) : SVG fins en trait, style rail
Spark — monochromes (currentColor), pas d'emoji. #}
{% macro nav_icon(key) %}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{% if key == 'home' %}<path d="M3 11l9-8 9 8"/><path d="M5 10v11h14V10"/><path d="M10 21v-6h4v6"/>{% elif key == 'focus' %}<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1" fill="currentColor"/>{% elif key == 'today' %}<path d="M7 18a5 5 0 0 1 10 0"/><path d="M3 18h18"/><path d="M12 9V6"/><path d="M5.5 11l1.5 1.5"/><path d="M18.5 11l-1.5 1.5"/>{% elif key == 'my' %}<circle cx="12" cy="8" r="3.5"/><path d="M4 20c0-3.5 3.5-5.5 8-5.5s8 2 8 5.5"/>{% elif key == 'briefing' %}<path d="M4 20V11"/><path d="M10 20V4"/><path d="M16 20v-8"/><path d="M3 20h18"/>{% elif key == 'standup' %}<path d="M4 5h16v11h-7l-4 4v-4H4z"/>{% elif key == 'digest' %}<path d="M6 3h12v18H6z"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/>{% elif key == 'inflight' %}<path d="M3 12l19-9-7 18-4-6z"/><path d="M11 15L22 3"/>{% elif key == 'forecast' %}<path d="M3 18l5-6 4 3 9-9"/><path d="M16 6h5v5"/>{% elif key == 'health' %}<path d="M12 20C7 16 3 13 3 9a4.5 4.5 0 0 1 8-2.5A4.5 4.5 0 0 1 21 9c0 4-4 7-9 11z"/>{% elif key == 'rules' %}<path d="M6 3h12v18H6z"/><path d="M9 12l2 2 4-4"/>{% elif key == 'activity' %}<path d="M4 4h16v16H4z"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h6"/>{% elif key == 'search' %}<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>{% elif key == 'capture' %}<path d="M13 2L4 14h6l-1 8 10-13h-6z"/>{% elif key == 'archives' %}<path d="M3 8h18"/><path d="M5 8v12h14V8"/><path d="M9 8v5h6V8"/>{% elif key == 'wall' %}<path d="M3 5h18v11H3z"/><path d="M9 21h6"/><path d="M12 16v5"/>{% elif key == 'admin' %}<path d="M4 4h7v7H4z"/><path d="M13 4h7v7h-7z"/><path d="M4 13h7v7H4z"/><path d="M13 13h7v7h-7z"/>{% elif key == 'admin_connections' %}<path d="M9 3v5"/><path d="M15 3v5"/><path d="M7 8h10v5a5 5 0 0 1-10 0z"/><path d="M12 18v3"/>{% elif key == 'admin_workspaces' %}<circle cx="12" cy="12" r="9"/><path d="M15 9l-2 4-4 2 2-4z"/>{% elif key == 'admin_columns' %}<path d="M4 4h4v16H4z"/><path d="M10 4h4v16h-4z"/><path d="M16 4h4v16h-4z"/>{% elif key == 'admin_settings' %}<circle cx="12" cy="12" r="3"/><path d="M12 2v3"/><path d="M12 19v3"/><path d="M2 12h3"/><path d="M19 12h3"/><path d="M4.5 4.5l2 2"/><path d="M17.5 17.5l2 2"/><path d="M19.5 4.5l-2 2"/><path d="M6.5 17.5l-2 2"/>{% else %}<circle cx="12" cy="12" r="9"/>{% endif %}</svg>{% endmacro %}
<nav class="side-nav">
{% for gkey, group, links in nav_groups %}
<div class="side-section" data-group="{{ gkey }}">
@@ -121,39 +106,26 @@
<div class="side-acc-inner">
{% for key, href, icon, label in links %}
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
<span class="side-ico">{{ nav_icon(key) }}</span>
<span class="side-ico">{{ icon }}</span>
<span class="side-label">{{ label }}</span>
</a>
{% endfor %}
{% if gkey == 'admin' %}
<button type="button" class="side-link theme-toggle-link" id="theme-toggle" title="Basculer entre thème sombre et clair" aria-label="Basculer entre thème sombre et clair">
<span class="side-ico" data-theme-ico>☀</span>
<span class="side-label" data-theme-label>Mode clair</span>
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</nav>
<div class="side-foot">
<div class="side-foot-user">
{% if current_user %}
<a href="/profile" class="side-logout side-profile" title="Mon profil"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" style="width:18px;height:18px;display:inline-block;vertical-align:-3px"><circle cx="12" cy="8" r="3.5"/><path d="M4 20c0-3.5 3.5-5.5 8-5.5s8 2 8 5.5"/></svg><span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
{% endif %}
{% if not is_admin %}
<button type="button" class="theme-toggle-foot" id="theme-toggle" title="Basculer entre thème sombre et clair" aria-label="Basculer entre thème sombre et clair"><span data-theme-ico>☀</span></button>
{% endif %}
{% if auth_enabled %}
<a href="/logout" class="side-logout logout-icon" title="Se déconnecter" aria-label="Se déconnecter">⏻</a>
{% endif %}
</div>
<div class="side-foot-meta">
<span class="side-version side-label">v{{ version }}</span>
{% if data_freshness %}
<span class="side-fresh" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan) — les données affichées datent de cet instantané"><span class="fresh-dot" aria-hidden="true"></span><span class="side-label">{{ data_freshness }}</span></span>
{% endif %}
</div>
{% 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>
{% endif %}
</div>
</aside>
<div class="side-backdrop" id="side-backdrop" hidden></div>
@@ -166,8 +138,9 @@
{% endif %}
<div class="header-actions">
{% if repo_info is defined and repo_info and repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">Gitea ↗</a>
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
{% endif %}
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
</div>
<script src="/static/js/nav.js?v={{ version }}" defer></script>
<script src="/static/js/nav.js" defer></script>
</header>
+1 -1
View File
@@ -1,7 +1,7 @@
<div class="palette-overlay" id="palette-overlay" hidden>
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
<div class="palette-input-row">
<span class="palette-icon"></span>
<span class="palette-icon">⌨️</span>
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
placeholder="Chercher un projet, une issue, une action…">
<kbd>Échap</kbd>
+14 -30
View File
@@ -12,18 +12,17 @@
<main class="focus-main">
<div class="page-head">
<h1>Administration</h1>
<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 %}
{% 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>
<p class="notes-hint">Correspondance draco (SSO, HUB-16) : chaque identité draco (<code>sub</code>) désigne <strong>son</strong> compte Kanbante. Un compte de suite sans correspondance est créé automatiquement à sa première connexion draco (JIT, rôle membre). Pour un compte Kanbante <strong>préexistant</strong>, c'est ici que l'administrateur établit la liaison : saisir le <code>sub</code> draco du compte puis « Lier » (champ affiché = délier). Un <code>sub</code> ne peut être lié qu'à un seul compte. Procédure complète : <code>docs/correspondance-draco.md</code>.</p>
<h2>👥 Utilisateurs ({{ users|length }})</h2>
<table class="proj-table">
<thead>
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco (sub)</th><th>Dernière connexion</th><th>Actions</th></tr>
<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 %}
@@ -38,23 +37,8 @@
</select>
</form>
</td>
<td>{% if u.active %}Actif{% else %}Désactivé{% endif %}</td>
<td>
{% if u.draco_sub %}
<code>{{ u.draco_sub }}</code>
<form method="post" action="/admin/users/{{ u.id }}/draco" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="draco_sub" value="">
<button type="submit" class="btn-mini">Délier</button>
</form>
{% else %}
<form method="post" action="/admin/users/{{ u.id }}/draco" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="draco_sub" placeholder="sub draco" size="12" autocomplete="off">
<button type="submit" class="btn-mini">Lier</button>
</form>
{% endif %}
</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">
@@ -72,7 +56,7 @@
</tbody>
</table>
<h3>Nouvel utilisateur</h3>
<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>
@@ -90,17 +74,17 @@
</section>
<section class="briefing-card">
<h2>Vue d'ensemble</h2>
<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>
<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>
<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>
+8 -8
View File
@@ -12,11 +12,11 @@
<main class="focus-main">
<div class="page-head">
<h1>Colonnes du board</h1>
<h1>🧱 Colonnes du board</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
</div>
{% if msg %}<p class="chip-ok">{{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">{{ err }}</p>{% endif %}
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>Comment marche le mapping</h2>
@@ -52,12 +52,12 @@
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn-mini" title="Monter">↑</button>
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
</form>
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn-mini" title="Descendre">↓</button>
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
</form>
{% if c.kind == 'flow' %}
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
@@ -75,10 +75,10 @@
{% endfor %}
<section class="briefing-card">
<h2>Nouvelle colonne</h2>
<h2>➕ Nouvelle colonne</h2>
<form method="post" action="/admin/columns" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" required placeholder="En développement"></label>
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
@@ -87,7 +87,7 @@
</section>
<section class="briefing-card">
<h2>Réinitialiser</h2>
<h2>↩️ Réinitialiser</h2>
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+6 -6
View File
@@ -12,15 +12,15 @@
<main class="focus-main">
<div class="page-head">
<h1>Connexions aux forges</h1>
<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 %}
{% 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' %}GitHub{% else %}Gitea{% endif %} {{ c.name }}
<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 %}
@@ -41,7 +41,7 @@
<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>
<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 }}">
@@ -56,7 +56,7 @@
{% endfor %}
<section class="briefing-card">
<h2>Nouvelle connexion</h2>
<h2>➕ Nouvelle connexion</h2>
<form method="post" action="/admin/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
+3 -3
View File
@@ -12,11 +12,11 @@
<main class="focus-main">
<div class="page-head">
<h1>Paramètres</h1>
<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 %}
{% 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">
+8 -8
View File
@@ -12,11 +12,11 @@
<main class="focus-main">
<div class="page-head">
<h1>Espaces de travail</h1>
<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 %}
{% 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 %}
@@ -30,19 +30,19 @@
<button type="submit" class="btn-save btn-inline">Renommer</button>
</form>
<h3>Connexions de l'espace</h3>
<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' %}GitHub{% else %}Gitea{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
{% 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>
<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>
@@ -81,7 +81,7 @@
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
</form>
<h3>Sélection de projets</h3>
<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 }}">
@@ -101,7 +101,7 @@
{% if is_global_admin %}
<section class="briefing-card">
<h2>Nouvel espace</h2>
<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>
+16 -16
View File
@@ -12,11 +12,11 @@
<main class="focus-main">
<div class="page-head">
<h1>Archives</h1>
<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 %}
{% 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 %}
@@ -26,7 +26,7 @@
<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>
<button type="submit" class="btn-mini">📥 Archiver ici</button>
</form>
{% endif %}
{% if kind == 'local' %}
@@ -35,7 +35,7 @@
<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>
<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 }}">
@@ -50,7 +50,7 @@
<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>
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
</form>
{% endif %}
{% if kind == 'forge' %}
@@ -59,14 +59,14 @@
<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>
<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>
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
</form>
{% endif %}
{% endmacro %}
@@ -74,19 +74,19 @@
{% macro rowline(row, kind) %}
<tr>
<td>
{% if row.conn_kind == 'github' %}GitHub{% else %}Gitea{% endif %}
{% 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>{% 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>
<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 }}">
@@ -96,15 +96,15 @@
{% 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' %}GitHub{% else %}Gitea{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</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>{% 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>
<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>
@@ -112,7 +112,7 @@
</section>
<section class="briefing-card">
<h2>Archives locales de cet espace ({{ groups.local|length }})</h2>
<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>
@@ -124,7 +124,7 @@
</section>
<section class="briefing-card">
<h2>Archivés sur la forge ({{ groups.forge|length }})</h2>
<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>
+19 -19
View File
@@ -16,7 +16,7 @@
{% if milestones %}
<div class="milestone-bar">
<span class="milestone-label">Milestone</span>
<span class="milestone-label">🎯 Milestone</span>
<a href="/board/{{ owner }}/{{ repo }}" class="milestone-chip{% if not milestone_param %} active{% endif %}">Toutes</a>
{% for m in milestones %}
<a href="/board/{{ owner }}/{{ repo }}?milestone={{ m.title|urlencode }}"
@@ -27,15 +27,15 @@
{% if milestone_progress %}
<div class="milestone-banner">
<div class="milestone-info">
<strong>{{ milestone_progress.title }}</strong>
<strong>🎯 {{ milestone_progress.title }}</strong>
{% if milestone_progress.due_on %}
<span class="milestone-due">échéance {{ milestone_progress.due_on[:10] }}</span>
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
{% endif %}
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
{% if milestone_forecast %}
<span class="milestone-forecast">{{ milestone_forecast }}</span>
<span class="milestone-forecast">🔮 {{ milestone_forecast }}</span>
{% endif %}
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">Récit de sprint</a>
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">📖 Récit de sprint</a>
</div>
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
@@ -44,19 +44,19 @@
<!-- Toolbar: text + label filters, and issue creation (Gitea-backed) -->
<div class="board-toolbar">
<input type="text" id="card-filter" placeholder="Filtrer les cartes... ( / )">
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes... ( / )">
<select id="label-filter">
<option value="">Toutes les étiquettes</option>
{% for label in labels %}
<option value="{{ label.name }}">{{ label.name }}</option>
{% endfor %}
</select>
<button type="button" id="labels-btn" class="btn-sm">Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">Triage du backlog</a>
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">Colonnes</a>{% endif %}
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
<form id="create-issue-form" class="composer">
<div class="composer-head">
<span class="composer-title">Nouvelle issue</span>
<span class="composer-title">➕ Nouvelle issue</span>
<span class="composer-target">arrive dans
<strong>{{ column_labels.backlog }}</strong>
</span>
@@ -102,7 +102,7 @@
<aside class="notes-panel" id="notes-panel">
<div class="notes-resize" id="notes-resize" title="Glisser pour ajuster la largeur"></div>
<div class="notes-head">
<h3>Notes du projet</h3>
<h3>📝 Notes du projet</h3>
<button type="button" id="notes-toggle" class="btn-sm notes-toggle"
title="Replier / déplier le panneau">⤢</button>
</div>
@@ -114,7 +114,7 @@
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
<div class="notes-foot">
<span id="notes-status" class="notes-status"></span>
<button type="submit" class="btn-save">Sauvegarder</button>
<button type="submit" class="btn-save">💾 Sauvegarder</button>
</div>
</form>
</div>
@@ -125,7 +125,7 @@
<div id="label-modal" class="modal" hidden>
<div class="modal-box">
<div class="detail-head">
<h2>Étiquettes de {{ full_name }}</h2>
<h2>🏷️ Étiquettes de {{ full_name }}</h2>
<button id="label-modal-close" class="btn-sm" type="button">✕</button>
</div>
<p class="notes-hint">Ces étiquettes vivent dans Gitea : les créer ou les supprimer ici les modifie pour tout le dépôt.</p>
@@ -161,29 +161,29 @@
<div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div>
<div class="detail-actions">
<button id="detail-edit-btn" class="btn-sm" type="button">Modifier le titre et la description</button>
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
</div>
<form id="detail-edit-form" hidden>
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
<textarea id="detail-body-input" rows="10" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<div class="detail-actions">
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
</div>
</form>
<h3 class="detail-comments-title">Pièces jointes</h3>
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
<div id="detail-assets"></div>
<form id="asset-form" class="asset-form">
<input type="file" id="asset-file">
<button type="submit" class="btn-sm">Joindre le fichier</button>
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
</form>
<h3 class="detail-comments-title">Commentaires</h3>
<h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div>
<form id="comment-form">
<textarea id="comment-body" rows="3" maxlength="10000"
placeholder="Écrire un commentaire (markdown)..."></textarea>
<button type="submit" class="btn-save">Commenter dans Gitea</button>
<button type="submit" class="btn-save">💬 Commenter dans Gitea</button>
</form>
<p class="detail-foot"><a id="detail-gitea-link" href="#" target="_blank">Ouvrir dans Gitea ↗</a></p>
</aside>
+4 -4
View File
@@ -22,17 +22,17 @@
</div>
<section class="briefing-card">
<h2>Contenu du briefing</h2>
<h2>📄 Contenu du briefing</h2>
<pre class="briefing-text">{{ text }}</pre>
</section>
<section class="briefing-card">
<h2>Envoi ntfy</h2>
<h2>📲 Envoi ntfy</h2>
{% if ntfy_configured %}
<p class="notes-hint">ntfy est configuré. {% if briefing_time %}Envoi automatique chaque jour à <strong>{{ briefing_time }}</strong> (heure du serveur).{% else %}Aucune heure d'envoi automatique (<code>KANBANTE_BRIEFING_TIME</code> non défini).{% endif %}{% if last_sent %} Dernier envoi : {{ last_sent }}.{% endif %}</p>
<form id="briefing-send-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-save">Envoyer maintenant via ntfy</button>
<button type="submit" class="btn-save">📤 Envoyer maintenant via ntfy</button>
</form>
<p id="briefing-status" class="notes-hint"></p>
{% else %}
@@ -52,7 +52,7 @@
status.textContent = 'Envoi en cours...';
const r = await fetch('/api/briefing/send', { method: 'POST', body: new FormData(e.target) });
const data = await r.json().catch(() => ({}));
status.textContent = data.ok ? 'Briefing envoyé sur ntfy.' : '' + (data.error || ('HTTP ' + r.status));
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
});
</script>
{% include "_palette.html" %}
+10 -10
View File
@@ -12,13 +12,13 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Capture éclair</h1>
<h1>⚡ Capture éclair</h1>
<p>Une idée, un bogue, une pensée — capturés en deux secondes, ils deviennent une issue dans le bon backlog.</p>
</section>
{% if created %}
<div class="briefing-card">
<h2>Capturé !</h2>
<h2>✅ Capturé !</h2>
<p>Issue <strong>#{{ created.number }}</strong> créée dans
<strong>{{ created.repo_full_name }}</strong> (colonne {{ created.column }}) :
{% if created.html_url %}<a href="{{ created.html_url }}" target="_blank">{{ created.title }}</a>{% else %}{{ created.title }}{% endif %}
@@ -27,13 +27,13 @@
{% endif %}
{% if error %}
<div class="briefing-card">
<h2>Capture impossible</h2>
<h2>❌ Capture impossible</h2>
<p>{{ error }}</p>
</div>
{% endif %}
<div class="briefing-card">
<h2>Saisie rapide</h2>
<h2>✍️ Saisie rapide</h2>
<form method="post" action="/capture" class="capture-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Dépôt
@@ -41,7 +41,7 @@
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
</span>
<span class="repo-hint" id="repo-hint" hidden>Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
</label>
<label>Titre de l'issue
@@ -52,16 +52,16 @@
</label>
<label>Colonne
<select name="column">
<option value="backlog" selected>Backlog</option>
<option value="todo">À faire</option>
<option value="backlog" selected>📥 Backlog</option>
<option value="todo">📋 À faire</option>
</select>
</label>
<button type="submit" class="btn-save">Capturer</button>
<button type="submit" class="btn-save">⚡ Capturer</button>
</form>
</div>
<div class="briefing-card">
<h2>Depuis un script ou un raccourci</h2>
<h2>🔌 Depuis un script ou un raccourci</h2>
{% if api_enabled %}
<p>L'endpoint <code>POST /api/capture</code> est <strong>actif</strong> (jeton de capture configuré). Exemples :</p>
<pre class="capture-pre">curl -X POST https://votre-kanbante/api/capture \
@@ -75,7 +75,7 @@
</div>
<div class="briefing-card">
<h2>Depuis le téléphone (ntfy)</h2>
<h2>📱 Depuis le téléphone (ntfy)</h2>
{% if ntfy_topic %}
<p>Écoute active sur le topic <code>{{ ntfy_topic }}</code> : chaque message publié devient une issue dans <code>{{ capture_repo or "— (KANBANTE_CAPTURE_REPO manquant)" }}</code>. La première ligne fait le titre. Publie depuis l'application ntfy ou :</p>
<pre class="capture-pre">curl -d "Idée : relire le rapport\n\nDétails…" {{ ntfy_url }}/{{ ntfy_topic }}</pre>
+46 -59
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanbante — Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/style.css">
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
@@ -55,7 +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>
<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">
@@ -64,7 +64,7 @@
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="review">En attente de revue ↓</option>
<option value="review">🔍 En attente de revue ↓</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
@@ -91,93 +91,80 @@
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<div class="card-header-left">
<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' %}GitHub{% else %}Gitea{% 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 %}
</div>
<div class="card-header-actions">
<span class="date" title="Dernière activité">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '' if repo.starred else '☆' }}</button>
<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 %}
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
<div class="card-menu-wrap">
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
<div class="card-menu" hidden>
<a href="/board/{{ full }}">Board</a>
<a href="/capture?repo={{ full|urlencode }}">Capturer ici</a>
<a href="/triage/{{ full }}">Triage du backlog</a>
<a href="/health/{{ full }}">Santé</a>
<a href="/sprint/{{ full }}">Récit de sprint</a>
<a href="/board/{{ full }}">📋 Board</a>
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
<a href="/health/{{ full }}">🩺 Santé</a>
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
{% if repo.html_url %}
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>
<div class="card-menu-sep" role="separator"></div>
<button type="button" class="card-menu-item" data-copy-url="{{ repo.html_url }}" title="Copier l'URL Gitea de {{ repo.name }}" aria-label="Copier l'URL du projet {{ repo.name }}">Copier l'URL du projet</button>
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<h3>
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc" title="{{ repo.description or '' }}">{{ repo.description or '—' }}</p>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
{% set total = stats[full].values()|sum %}
{% set done_n = stats[full].get('done', 0) %}
{% if total > 0 %}
<div class="card-progress">
<div class="progress-head"><span>Avancement</span><span>{{ done_n }}/{{ total }} terminées</span></div>
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
</div>
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
</div>
{% endif %}
{% endif %}
{% if repo.next_milestone %}
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-head"><span class="ms-kicker">Prochain jalon</span>{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">échéance {{ ms.due_label }}</span>{% endif %}</div>
<div class="ms-line">
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">{{ ms.title }}</a>
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · {{ ms.eta_label }}{% if ms.eta_late %} — après l'échéance{% endif %}{% endif %}</div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% if ms.eta_late %} — ⚠️ après l'échéance{% endif %}{% endif %}</div>
{% endif %}
</div>
{% endif %}
<div class="card-footer">
<div class="foot-main">
<span class="foot-count">{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link btn-sm">Board →</a>
</div>
<div class="foot-dist">
{% if full in stats %}
<div class="mini-progress" aria-label="Répartition par colonne">
{% for col in column_keys %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" title="{{ COLUMN_LABELS[col] }}" style="background: color-mix(in srgb, {{ COLUMN_COLORS[col] }} 25%, transparent); color: var(--text)">
{{ COLUMN_LABELS[col] }} {{ n }}
</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">{{ repo.forecast_label }}</span>
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
{% endif %}
{% if full in stats %}
<div class="mini-progress">
{% for col in column_keys %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
{{ n }}
</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% if repo.activity %}
<div class="activity-line">
{% if repo.activity.closed or repo.activity.moved %}
Cette semaine : {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s)
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
aucune activité kanban depuis {{ repo.activity.idle_days }} j
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
{% endif %}
</div>
{% endif %}
@@ -197,7 +184,7 @@
<td>{{ repo.owner.login }}</td>
<td>{{ repo.open_issues_count or 0 }}</td>
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
<td>{% if repo.next_milestone %}{{ repo.next_milestone.title }}{% if repo.next_milestone.late %} {% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
<td>{{ repo.updated_at[:10] }}</td>
</tr>
@@ -230,7 +217,7 @@
<div x-show="view === 'grid'">
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
<h3 class="pinned-title">Épinglés</h3>
<h3 class="pinned-title">⭐ Épinglés</h3>
<div class="project-grid" id="pinned-grid">
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
</div>
@@ -239,21 +226,21 @@
<div x-show="tab === 'active'" class="project-grid" id="grid-active">
{% for repo in active_listed %}{{ project_card(repo) }}{% endfor %}
{% if not active_listed and not pinned_repos %}
<div class="empty-state">Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_listed %}
<div class="empty-state">Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid" id="grid-archived">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
{% endif %}
</div>
</div>
@@ -272,7 +259,7 @@
<script src="/static/js/live.js" defer></script>
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script src="/static/js/dashboard.js?v={{ version }}" defer></script>
<script src="/static/js/dashboard.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
+9 -9
View File
@@ -12,11 +12,11 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Digest hebdo → ObsiGate</h1>
<h1>🧠 Digest hebdo → ObsiGate</h1>
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
{% if last_result %}<p>Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
{% if digest_time %}<p>Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
</section>
{% if banner %}
@@ -25,7 +25,7 @@
{% if tools is not none %}
<div class="briefing-card">
<h2>Outils du serveur MCP ObsiGate</h2>
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
{% if tools %}
<ul>
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
@@ -38,29 +38,29 @@
{% endif %}
<div class="briefing-card">
<h2>Livraison</h2>
<h2>📤 Livraison</h2>
<p>
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
</p>
<form method="post" action="/digest/probe" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-save">Tester la connexion MCP</button>
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="obsigate">
<button type="submit" class="btn-save">Envoyer vers ObsiGate</button>
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="webhook">
<button type="submit" class="btn-save">Envoyer au webhook</button>
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
</form>
</div>
<div class="briefing-card">
<h2>Aperçu du digest de la semaine</h2>
<h2>👀 Aperçu du digest de la semaine</h2>
{% if pending %}
<pre class="capture-pre">{{ pending }}</pre>
{% else %}
+1 -1
View File
@@ -10,7 +10,7 @@
{% include "_header.html" %}
<main class="error-main">
<div class="error-box">
<h2>Quelque chose s'est mal passé</h2>
<h2>⚠️ Quelque chose s'est mal passé</h2>
<p>{{ message }}</p>
<p><a href="/" class="btn-sm">← Retour au dashboard</a></p>
</div>
+4 -4
View File
@@ -11,9 +11,9 @@
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head"><div class="page-title"><h1>Focus</h1><p class="page-sub">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p></div></div>
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
{% if not groups %}
<p class="focus-empty">Rien en cours ni en révision pour le moment.</p>
<p class="focus-empty">🎉 Rien en cours ni en révision pour le moment.</p>
{% endif %}
{% for group in groups %}
<section class="focus-group">
@@ -26,7 +26,7 @@
<div class="kanban-card focus-card">
<div class="card-id">
#{{ card.issue.number }}
<span class="chip" style="background: color-mix(in srgb, {{ column_colors[card.column] }} 25%, transparent); color: var(--text)">
<span class="chip" style="background: {{ column_colors[card.column] }}20; color: {{ column_colors[card.column] }}">
{{ column_labels[card.column] }}
</span>
{% if card.issue.html_url %}
@@ -37,7 +37,7 @@
{% if card.issue.labels %}
<div class="card-labels">
{% for label in card.issue.labels %}
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
+10 -10
View File
@@ -12,15 +12,15 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Prévisions — « on finit quand ? »</h1>
<h1>🔮 Prévisions — « on finit quand ? »</h1>
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
<p class="forecast-summary">
<span class="chip chip-total">{{ summary.total }} jalon(s) ouvert(s)</span>
{% if summary.late %}<span class="chip chip-late">{{ summary.late }} en retard à ce rythme</span>{% endif %}
{% if summary.ontime %}<span class="chip chip-ok">{{ summary.ontime }} dans les temps</span>{% endif %}
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
</p>
<div class="search-bar forecast-filter">
<input type="text" id="ms-filter" placeholder="Filtrer par projet ou jalon..." autocomplete="off">
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
</div>
</section>
@@ -56,9 +56,9 @@
<td>{{ row.eta or '—' }}</td>
<td>
{% if row.verdict == 'late' %}
<span class="verdict verdict-late">+{{ row.gap_days }} j</span>
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
{% elif row.verdict == 'ontime' %}
<span class="verdict verdict-ok">dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
{% elif row.verdict == 'nodue' %}
<span class="verdict">sans échéance</span>
{% else %}
@@ -75,10 +75,10 @@
{% endif %}
<section class="briefing-card">
<h2>Débit par projet</h2>
<h2>📈 Débit par projet</h2>
{% for project in projects %}
<div class="forecast-flex">
<div class="forecast-proj-name"><a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
{% for bar in project.bars %}
{% set px = (bar.h * 0.42)|round|int %}
@@ -93,7 +93,7 @@
{% else %}
<p>Débit : <strong>pas assez d'historique</strong>.</p>
{% endif %}
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · backlog {{ project.eta_label }}{% endif %}</p>
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
</div>
</div>
{% else %}
+3 -3
View File
@@ -12,7 +12,7 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Santé du flux</h1>
<h1>🩺 Santé du flux</h1>
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</p>
</section>
@@ -23,8 +23,8 @@
<th data-sort="severity">Santé</th>
<th data-sort="project">Projet</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="zombies" data-first-dir="desc">Zombies</th>
<th data-sort="slowest">Goulot</th>
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
<th data-sort="slowest">🐌 Goulot</th>
<th></th>
</tr>
</thead>
+11 -11
View File
@@ -13,7 +13,7 @@
<main class="focus-main">
{% if project_names %}
<div class="health-switch-bar">
<label for="health-switch">Projet :</label>
<label for="health-switch">🩺 Projet :</label>
<select id="health-switch">
{% for name in project_names %}
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
@@ -25,20 +25,20 @@
{% endif %}
{% if health is none %}
<section class="briefing-card">
<h1>{{ full_name }}</h1>
<h1>🩺 {{ full_name }}</h1>
<p>Projet introuvable.</p>
</section>
{% else %}
<section class="briefing-card">
<h1>{{ full_name }}</h1>
<h2>Temps passé par colonne (journal)</h2>
<h1>🩺 {{ full_name }}</h1>
<h2>⏱️ Temps passé par colonne (journal)</h2>
{% if health.dwell %}
<table class="health-table">
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
<tbody>
{% for row in health.dwell %}
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %}{% endif %}</td>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
<td>{{ row.count }}</td>
<td>{{ row.median_days }} j</td>
<td>{{ row.avg_days }} j</td>
@@ -47,7 +47,7 @@
</tbody>
</table>
{% if health.slowest %}
<p><strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
{% endif %}
{% else %}
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
@@ -55,23 +55,23 @@
</section>
<section class="briefing-card">
<h2>Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
{% if health.zombies %}
<ul class="today-list">
{% for z in health.zombies %}
<li>
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
<span class="zombie-actions">
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">Fermer</button>
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
</span>
</li>
{% endfor %}
</ul>
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
{% else %}
<p>Aucune carte zombie. Le flux est propre. </p>
<p>Aucune carte zombie. Le flux est propre. ✨</p>
{% endif %}
</section>
{% endif %}
+5 -5
View File
@@ -15,16 +15,16 @@
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
<div class="inflight-summary">
<span class="inflight-total">{{ total }} carte{{ 's' if total != 1 }} en vol</span>
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
{% for group in groups %}
<span class="col-chip" style="background: color-mix(in srgb, {{ column_colors[group.column] }} 25%, transparent); color: var(--text)">
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
</span>
{% endfor %}
</div>
{% if not groups %}
<p class="focus-empty">Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
{% endif %}
{% for group in groups %}
@@ -51,7 +51,7 @@
{% if card.issue.labels %}
<div class="card-labels">
{% for label in card.issue.labels %}
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
@@ -62,7 +62,7 @@
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
{% endif %}
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">{{ card.age_days }} j dans la colonne</span>
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
</div>
</div>
{% endfor %}
+4 -5
View File
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<script>try{var t=localStorage.getItem("kanbante-theme");document.documentElement.setAttribute("data-theme",t==="light"?"light":"dark");}catch(e){document.documentElement.setAttribute("data-theme","dark");}</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Connexion — Kanbante</title>
@@ -12,12 +11,12 @@
<main class="login-wrap">
<section class="login-card">
<div class="login-brand">
<span class="login-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="#fff" aria-hidden="true" style="width:20px;height:20px;display:block"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="9.5" y="4" width="5" height="10" rx="1"/><rect x="16" y="4" width="5" height="13" rx="1"/></svg></span>
<span class="login-mark" aria-hidden="true">📋</span>
<h1>Kanbante</h1>
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
</div>
{% if error %}
<p class="login-error" role="alert">{{ error }}</p>
<p class="login-error" role="alert">⚠️ {{ error }}</p>
{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -30,7 +29,7 @@
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<a class="login-sso" href="{{ sso_authorize_url }}">Se connecter avec ObsiGate (SSO)</a>
<a class="login-sso" href="{{ sso_authorize_url }}">🧭 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>
+7 -7
View File
@@ -12,7 +12,7 @@
<main class="focus-main">
{% if not user %}
<div class="empty-state">Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
{% else %}
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
@@ -20,7 +20,7 @@
<div class="my-filters" id="my-filters">
<div class="my-filters-row">
<div class="my-search-wrap">
<span class="my-search-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" style="width:18px;height:18px;display:block"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg></span>
<span class="my-search-icon" aria-hidden="true">🔍</span>
<input type="search" id="my-search" autocomplete="off"
placeholder="Rechercher un titre, un projet, un #numéro, une étiquette… ( / )">
</div>
@@ -58,11 +58,11 @@
</div>
{% endif %}
<div class="empty-state" id="my-empty" hidden>Aucune issue ne correspond à ces filtres.
<div class="empty-state" id="my-empty" hidden>🔍 Aucune issue ne correspond à ces filtres.
<button type="button" id="my-empty-reset" class="btn-sm">↺ Réinitialiser les filtres</button>
</div>
{% if not groups %}
<div class="empty-state">Aucune issue ne vous est assignée pour le moment.</div>
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
{% endif %}
{% for group in groups %}
<section class="focus-group" data-repo="{{ group.full_name }}">
@@ -77,13 +77,13 @@
data-updated="{{ issue.updated_at or '' }}"
data-title="{{ issue.title }}"
data-search="#{{ issue.number }} {{ issue.title }} {{ group.full_name }} {{ (issue.labels or [])|map(attribute='name')|join(' ') }}">
<span class="issue-state" aria-label="Ouverte">●</span>
<span class="issue-state">🟢</span>
<a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
</a>
<span class="col-chip" style="background: color-mix(in srgb, {{ column_colors[row.column] }} 25%, transparent); color: var(--text)">{{ column_labels[row.column] }}</span>
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
{% for label in issue.labels or [] %}
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">{{ label.name }}</span>
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
{% endfor %}
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
</div>
+12 -12
View File
@@ -12,12 +12,12 @@
<main class="focus-main">
<div class="page-head">
<h1>Mon profil</h1>
<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 %}
{% 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>
@@ -33,7 +33,7 @@
</section>
<section class="briefing-card">
<h2>Mot de passe</h2>
<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 }}">
@@ -47,11 +47,11 @@
</section>
<section class="briefing-card">
<h2>Jetons d'API (MCP)</h2>
<h2>🗝️ Jetons d'API (MCP)</h2>
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
{% if profile_user.id %}
{% if nouveau_jeton %}
<p>Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
{% endif %}
{% if api_tokens %}
@@ -84,7 +84,7 @@
</section>
<section class="briefing-card">
<h2>Liaison draco (SSO)</h2>
<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">
@@ -92,20 +92,20 @@
<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) ; un administrateur peut aussi l'établir depuis la page Administration. Sans compte lié, la première connexion draco d'une identité de suite crée automatiquement son propre compte Kanbante (JIT).</p>
<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>
<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' %}GitHub{% else %}Gitea{% endif %} <strong>{{ c.name }}</strong>
{% 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>
<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 }}">
+24 -24
View File
@@ -15,7 +15,7 @@
<main class="project-main">
<div class="proj-head">
<div>
<h1>{{ repo_info.name }}</h1>
<h1>📦 {{ repo_info.name }}</h1>
<p class="proj-sub">{{ owner }} · {{ repo_info.description or 'Aucune description dans Gitea.' }}</p>
</div>
{% if health_dot %}
@@ -26,42 +26,42 @@
</div>
<div class="proj-actions">
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">Ouvrir le board</a>
<a href="/capture" class="nav-pill">Capture</a>
<a href="/triage/{{ full_name }}" class="nav-pill">Triage</a>
<a href="/health/{{ full_name }}" class="nav-pill">Santé</a>
<a href="/forecast" class="nav-pill">Prévisions</a>
<a href="/wall?repo={{ full_name }}" class="nav-pill">Wallboard</a>
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/triage/{{ full_name }}" class="nav-pill">📥 Triage</a>
<a href="/health/{{ full_name }}" class="nav-pill">🩺 Santé</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall?repo={{ full_name }}" class="nav-pill">🖥️ Wallboard</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">Gitea ↗</a>
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</div>
<div class="proj-grid">
<section class="proj-panel">
<h2>Jalons ouverts</h2>
<h2>🎯 Jalons ouverts</h2>
{% if milestones %}
{% for ms in milestones %}
<div class="proj-ms">
<div class="ms-line">
<a href="/board/{{ full_name }}?milestone={{ ms.title|urlencode }}"><strong>{{ ms.title }}</strong></a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · {{ ms.eta_label }}{% endif %}</div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% endif %}</div>
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
{% if ms.id %}
<details class="ms-edit">
<summary>Modifier / fermer</summary>
<summary>✏️ Modifier / fermer</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
<span class="ms-hint">vide = retirer l'échéance</span></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
</div>
</form>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
@@ -76,29 +76,29 @@
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
{% endif %}
<details class="ms-edit ms-new">
<summary>Nouveau jalon</summary>
<summary>➕ Nouveau jalon</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
<label>Échéance <input type="date" name="due_on"></label>
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">Créer le jalon</button>
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
</div>
</form>
</details>
</section>
<section class="proj-panel">
<h2>Santé du flux</h2>
<h2>🩺 Santé du flux</h2>
{% if health %}
<ul class="proj-list">
<li><strong>{{ health.open_count }}</strong> cartes ouvertes</li>
<li>📂 <strong>{{ health.open_count }}</strong> cartes ouvertes</li>
{% if health.slowest %}
<li>Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
<li>🐢 Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
{% else %}
<li>Pas encore de goulot mesurable (historique trop court).</li>
<li>🐢 Pas encore de goulot mesurable (historique trop court).</li>
{% endif %}
<li>Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
<li>🧟 Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
</ul>
{% if health.zombies %}
<ul class="proj-list proj-zombies">
@@ -114,7 +114,7 @@
</section>
<section class="proj-panel">
<h2>Prévisions</h2>
<h2>🔮 Prévisions</h2>
{% if throughput %}
<ul class="proj-list">
<li>Débit : <strong>{{ throughput.avg }}</strong> fermetures / semaine en moyenne (min {{ throughput.min }} · max {{ throughput.max }}, 4 dernières semaines complètes)</li>
@@ -127,7 +127,7 @@
</section>
<section class="proj-panel">
<h2>Activité récente</h2>
<h2>🕘 Activité récente</h2>
{% if events %}
<ul class="proj-list">
{% for e in events %}
@@ -141,11 +141,11 @@
</section>
<section class="proj-panel proj-notes">
<h2>Notes du projet</h2>
<h2>📝 Notes du projet</h2>
{% if notes %}
<div class="proj-notes-body">{{ notes }}</div>
{% else %}
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « Notes » du board et vivent avec le projet.</p>
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « 🗒️ Notes » du board et vivent avec le projet.</p>
{% endif %}
<p><a href="/board/{{ full_name }}">Ouvrir le board pour éditer →</a></p>
</section>
+3 -9
View File
@@ -6,7 +6,7 @@
<title>Rechercher partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/recherche.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/recherche.css">
</head>
<body>
{% include "_header.html" %}
@@ -16,10 +16,9 @@
data-base-flowdeck="{{ draco_urls.flowdeck }}"
data-base-tiktak="{{ draco_urls.tiktak }}"
data-base-kanbante="{{ draco_urls.kanbante }}"
data-base-shaarit="{{ draco_urls.shaarit }}"
data-base-console="{{ agent_url }}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante, bookmarks ShaarIt — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
@@ -30,7 +29,6 @@
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="shaarit">ShaarIt <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
@@ -49,10 +47,6 @@
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="shaarit">
<div class="rp-head"><h2>ShaarIt — bookmarks</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
@@ -62,6 +56,6 @@
<footer>
Kanbante v{{ version }} · recherche partout
</footer>
<script src="/static/js/recherche.js?v={{ version }}" defer></script>
<script src="/static/js/recherche.js" defer></script>
</body>
</html>
+7 -7
View File
@@ -12,7 +12,7 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Règles d'automatisation</h1>
<h1>⚙️ Règles d'automatisation</h1>
<p>Des réflexes déclaratifs, évalués toutes les 15 minutes par le planificateur (et à la demande ci-dessous). Chaque action est journalisée comme événement <code>rule</code>. <strong>Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes</strong> — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.</p>
{% if not configured %}
<p>Aucune règle configurée. Définis <code>KANBANTE_RULES</code> en JSON, par exemple :</p>
@@ -25,15 +25,15 @@
<p class="notes-hint">Conditions : <code>column</code>, <code>min_age_days</code> (temps dans la colonne), <code>label</code> (présente), <code>wip_over</code> (colonne au-delà de N cartes). Actions : <code>add_label</code>, <code>notify</code>, <code>move_top</code>.</p>
{% endif %}
{% for err in errors %}
<p class="chip-warn">{{ err }}</p>
<p class="chip-warn">⚠️ {{ err }}</p>
{% endfor %}
<p><button type="button" class="btn-save btn-inline" id="rules-run">Évaluer maintenant</button>
<p><button type="button" class="btn-save btn-inline" id="rules-run">▶️ Évaluer maintenant</button>
<span id="rules-run-result" class="notes-hint"></span></p>
</section>
{% if rules %}
<section class="briefing-card">
<h2>Règles actives ({{ rules|length }})</h2>
<h2>📜 Règles actives ({{ rules|length }})</h2>
<ul class="forecast-milestones">
{% for rule in rules %}
<li><strong>{{ rule.name }}</strong> — quand
@@ -46,7 +46,7 @@
{% if last_run %}
<section class="briefing-card">
<h2>Dernière évaluation — {{ last_run.at }}</h2>
<h2>🕐 Dernière évaluation — {{ last_run.at }}</h2>
{% if last_run.actions %}
<ul class="forecast-milestones">
{% for action in last_run.actions %}
@@ -75,10 +75,10 @@
const r = await fetch('/api/rules/run', { method: 'POST', body: data });
const body = await r.json();
if (!r.ok || !body.ok) throw new Error((body && body.error) || ('HTTP ' + r.status));
out.textContent = '' + body.summary.actions.length + ' action(s) — rechargement…';
out.textContent = '✔ ' + body.summary.actions.length + ' action(s) — rechargement…';
setTimeout(() => location.reload(), 700);
} catch (e) {
out.textContent = '' + e.message;
out.textContent = '❌ ' + e.message;
}
});
</script>
+5 -5
View File
@@ -20,7 +20,7 @@
{% if q %}
<p class="focus-intro">{{ total }} résultat(s) pour « {{ q }} » — recherche Gitea, tous projets confondus.</p>
{% if not groups %}
<div class="empty-state">Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
<div class="empty-state">🔍 Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
{% endif %}
{% for group in groups %}
<section class="focus-group">
@@ -30,13 +30,13 @@
{% for row in group.rows %}
{% set issue = row.issue %}
<div class="issue-row">
<span class="issue-state">{{ '✓' if issue.state != 'open' else '●' }}</span>
<span class="issue-state">{{ '✅' if issue.state != 'open' else '🟢' }}</span>
<a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
</a>
<span class="col-chip" style="background: color-mix(in srgb, {{ column_colors[row.column] }} 25%, transparent); color: var(--text)">{{ column_labels[row.column] }}</span>
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
{% for label in issue.labels or [] %}
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">{{ label.name }}</span>
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
{% endfor %}
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
</div>
@@ -45,7 +45,7 @@
</section>
{% endfor %}
{% else %}
<div class="empty-state">Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
<div class="empty-state">🔍 Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
{% endif %}
</main>
+7 -7
View File
@@ -15,11 +15,11 @@
<main class="focus-main">
{% if markdown %}
<section class="briefing-card">
<h1>{{ milestone_title }}</h1>
<h1>📖 {{ milestone_title }}</h1>
<p>
<button type="button" class="btn-save btn-inline" id="sprint-copy">Copier le markdown</button>
<button type="button" class="btn-save btn-inline" id="sprint-download">Télécharger (.md)</button>
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">Voir le board du milestone</a>
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
</p>
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
</section>
@@ -31,7 +31,7 @@
document.getElementById('sprint-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(SPRINT_MD);
document.getElementById('sprint-copy').textContent = 'Copié !';
document.getElementById('sprint-copy').textContent = '✅ Copié !';
} catch (e) { alert('Copie impossible : ' + e); }
});
document.getElementById('sprint-download').addEventListener('click', () => {
@@ -45,11 +45,11 @@
</script>
{% else %}
<section class="briefing-card">
<h1>Récit de sprint — {{ full_name }}</h1>
<h1>📖 Récit de sprint — {{ full_name }}</h1>
<p>Choisis un milestone pour générer son récit :</p>
<ul>
{% for m in milestones %}
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">{{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
{% else %}
<li>Aucun milestone dans ce projet.</li>
{% endfor %}
+4 -4
View File
@@ -12,12 +12,12 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Standup automatique</h1>
<h1>🗣️ Standup automatique</h1>
<p>Écrit par le journal, pas par la mémoire : fermé/déplacé <strong>hier</strong>, focus <strong>aujourd'hui</strong>, cartes qui vieillissent en QA/Révision en <strong>blocages</strong>.</p>
<p>
<button type="button" class="btn-save btn-inline" id="standup-copy">Copier le standup</button>
<button type="button" class="btn-save btn-inline" id="standup-copy">📋 Copier le standup</button>
{% if include_in_briefing %}
<span class="chip-ok">Inclus dans le briefing ntfy du matin </span>
<span class="chip-ok">Inclus dans le briefing ntfy du matin ✔</span>
{% else %}
<span class="notes-hint">Astuce : <code>KANBANTE_BRIEFING_INCLUDE_STANDUP=1</code> l'ajoute au briefing ntfy du matin.</span>
{% endif %}
@@ -37,7 +37,7 @@
const text = document.getElementById('standup-text').textContent;
try {
await navigator.clipboard.writeText(text);
document.getElementById('standup-copy').textContent = 'Copié !';
document.getElementById('standup-copy').textContent = '✅ Copié !';
} catch (e) { alert('Copie impossible : ' + e); }
});
</script>
+11 -10
View File
@@ -11,7 +11,8 @@
{% include "_header.html" %}
<main class="today-main">
<div class="page-head"><div class="page-title"><h1 class="today-title">{{ date_label }}</h1><p class="page-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p></div></div>
<h1 class="today-title">🌅 {{ date_label }}</h1>
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
{% if picks %}
<section class="today-picks">
@@ -21,24 +22,24 @@
<span class="today-card-repo">{{ pick.repo }}</span>
<span class="today-card-col">{{ pick.column_label }}</span>
</div>
<h2>{% if pick.state == 'closed' %}{% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
<div class="today-card-actions">
<a href="/board/{{ pick.repo }}">Ouvrir le board</a>
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">Retirer</button>
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
</div>
</article>
{% endfor %}
</section>
{% else %}
<section class="briefing-card">
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton épingler sur les cartes) ou depuis les candidates ci-dessous.</p>
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
</section>
{% endif %}
{% if recap %}
<section class="briefing-card">
<h2>Ça a bougé aujourd'hui</h2>
<h2>🌆 Ça a bougé aujourd'hui</h2>
<ul>
{% for event in recap %}
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
@@ -49,12 +50,12 @@
{% if carry_over %}
<section class="briefing-card">
<h2>À reprendre d'hier</h2>
<h2>↩️ À reprendre d'hier</h2>
<ul class="today-list">
{% for pick in carry_over %}
<li>
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">Reprendre aujourd'hui</button>{% endif %}
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
</li>
{% endfor %}
</ul>
@@ -63,12 +64,12 @@
{% if candidates and picks|length < 3 %}
<section class="briefing-card">
<h2>Candidates (encours actuel)</h2>
<h2>🎯 Candidates (encours actuel)</h2>
<ul class="today-list">
{% for card in candidates %}
<li>
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">Épingler</button>
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
</li>
{% endfor %}
</ul>
+3 -3
View File
@@ -12,20 +12,20 @@
<main class="focus-main">
<section class="briefing-card">
<h1>Mode Triage</h1>
<h1>📥 Mode Triage</h1>
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
</section>
{% if projects %}
<div class="triage-projects">
{% for p in projects %}
<a class="triage-project" href="/triage/{{ p.full_name }}">
<span class="triage-project-name">{{ p.full_name }}</span>
<span class="triage-project-name">📂 {{ p.full_name }}</span>
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="briefing-card"><p>Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
{% endif %}
</main>
<footer>
+15 -15
View File
@@ -12,7 +12,7 @@
<main class="triage-main" data-repo="{{ full_name }}">
<div class="triage-head">
<h1>Triage — {{ full_name }}</h1>
<h1>📥 Triage — {{ full_name }}</h1>
<div class="triage-progress-wrap">
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
<span class="triage-progress-label" id="triage-progress-label"></span>
@@ -32,32 +32,32 @@
</section>
<section class="triage-done" id="triage-done" hidden>
<h2>Session terminée</h2>
<h2>🎉 Session terminée</h2>
<ul class="triage-summary" id="triage-summary"></ul>
<p>
<a class="btn-save btn-inline" href="/board/{{ full_name }}">Retour au board</a>
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">Nouvelle session</a>
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
</p>
</section>
<div class="triage-status" id="triage-status" role="status"></div>
<section class="triage-keys" id="triage-keys">
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> À faire</button>
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> En cours</button>
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> QA</button>
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> Révision</button>
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> Terminé</button>
<button type="button" data-act="labels"><kbd>L</kbd> Étiquettes</button>
<button type="button" data-act="assign"><kbd>A</kbd> M'assigner</button>
<button type="button" data-act="close"><kbd>X</kbd> Fermer</button>
<button type="button" data-act="next"><kbd>Entrée</kbd> Suivante</button>
<button type="button" data-act="undo"><kbd>Z</kbd> Annuler</button>
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</button>
</section>
<div class="triage-labels" id="triage-labels" hidden>
<div class="triage-labels-head">
<strong>Étiquettes de la carte</strong>
<strong>🏷️ Étiquettes de la carte</strong>
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
</div>
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
+8 -9
View File
@@ -2,8 +2,7 @@
<html lang="fr">
<head>
<meta charset="UTF-8">
<script>try{var t=localStorage.getItem("kanbante-theme");document.documentElement.setAttribute("data-theme",t==="light"?"light":"dark");}catch(e){document.documentElement.setAttribute("data-theme","dark");}</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanbante — Wallboard</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
@@ -14,7 +13,7 @@
<section class="wall-slide{% if loop.first %} active{% endif %}">
<header class="wall-head">
<div>
<div class="wall-kicker">Projet</div>
<div class="wall-kicker">📂 Projet</div>
<h1 class="wall-project">{{ slide.full_name }}</h1>
</div>
<div class="wall-clock">
@@ -42,7 +41,7 @@
<span class="wall-card-col" style="--col: {{ column_colors[card.column] }}">{{ card.column_label }}</span>
<span class="wall-card-title">#{{ card.number }} · {{ card.title }}</span>
{% if card.age_days is not none and card.age_days >= 1 %}
<span class="wall-card-age{% if card.aging %} hot{% endif %}">{{ card.age_days }} j</span>
<span class="wall-card-age{% if card.aging %} hot{% endif %}">⏳ {{ card.age_days }} j</span>
{% endif %}
</li>
{% endfor %}
@@ -51,12 +50,12 @@
{% endif %}
</ul>
{% else %}
<p class="wall-idle">Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
{% endif %}
<footer class="wall-foot">
<span>Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
<span>Ouvertes : <strong>{{ slide.open_total }}</strong></span>
<span>✅ Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
<span>📋 Ouvertes : <strong>{{ slide.open_total }}</strong></span>
<span class="wall-live">● EN DIRECT</span>
</footer>
</section>
@@ -67,12 +66,12 @@
<button type="button" class="wall-nav-btn" id="wall-prev" title="Projet précédent (←)">❮</button>
<div class="wall-dots" id="wall-dots"></div>
<button type="button" class="wall-nav-btn" id="wall-next" title="Projet suivant (→)">❯</button>
<div class="wall-paused" id="wall-paused" hidden>Rotation en pause — Espace pour reprendre</div>
<div class="wall-paused" id="wall-paused" hidden>⏸️ Rotation en pause — Espace pour reprendre</div>
</nav>
{% endif %}
{% else %}
<div class="wall-empty">
<h1>Wallboard</h1>
<h1>🖥️ Wallboard</h1>
<p>Aucun projet actif à afficher{% if single %} pour ce dépôt{% endif %}.</p>
<p class="wall-empty-hint">Astuce : <code>/wall?repo=Proprietaire/depot</code> fige un seul projet.</p>
</div>
-167
View File
@@ -1,167 +0,0 @@
# Correspondance draco par utilisateur (HUB-16, 0.46.0)
Kanbante participe au SSO de la suite dracodev : ObsiGate émet le
cookie `draco_session` (JWS HS256, claims `sub`, `name`, `iat`,
`exp`, `jti`), Kanbante le valide localement (`app/draco.py`) et le
pont `GET /auth/draco` le convertit en session Kanbante native.
Depuis la 0.46.0, un jeton draco valide désigne **le compte
Kanbante de cette identité** — et aucun autre. L'ancienne
hypothèse mono-utilisateur (« un jeton valide = l'opérateur ») a
disparu. C'est ce qui fait tenir le critère de la suite : « un
compte créé fonctionne dans toutes les applications ».
## Où vit la correspondance
Dans le modèle existant des comptes : la colonne
**`users.draco_sub`** (texte, `UNIQUE`, présente depuis la 0.24.0)
porte le `sub` draco lié à chaque compte Kanbante.
`models.get_user_by_draco(sub)` est la résolution canonique ; la
règle de décision complète vit dans
`app.accounts.resolve_draco_user()`, seul point d'entrée du pont
et du contrôle draco de `/api/search`. La correspondance est donc
biunivoque : un `sub` ↔ un compte, garanti par la contrainte
d'unicité.
## Résolution d'une connexion draco (`/auth/draco`)
Dans l'ordre :
1. **Session native déjà ouverte** : si le navigateur porte à la
fois une session Kanbante et un cookie draco dont le `sub`
n'est lié à aucun compte, le `sub` est lié au compte de la
session (première visite doublement connectée — mécanisme
historique, inchangé, aussi appliqué à la connexion native
dans `login_submit`).
2. **Correspondance existante** : la session du compte lié est
ouverte. Un compte **désactivé** n'ouvre aucune session et
n'est jamais contourné (voir § Désactivation).
3. **Compte d'amorçage** : s'il n'existe qu'**un seul compte** et
que c'est un **admin actif sans liaison**, le premier `sub`
valide s'y lie et sa session est ouverte. C'est le cas du
compte créé à l'installation depuis l'environnement
(`KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD`, Bruno) : sa
correspondance s'établit au premier passage, sans le verrouiller
dehors. Ses autres accès restent inchangés dans tous les cas :
connexion native avec le mot de passe du compte, et repli
d'urgence par les identifiants d'environnement (formulaire et
Basic), qui ne dépendent pas de draco.
4. **Création JIT** : sinon, un compte Kanbante est créé pour
cette identité (détail ci-dessous) et sa session est ouverte.
Un `sub` inconnu n'ouvre **jamais** la session de l'opérateur
d'environnement ni celle d'un compte existant non lié — y compris
si le nom affiché draco ressemble au nom d'un compte local : le
JIT crée toujours un compte **nouveau** (nom suffixé `-2`, `-3`…
en cas de collision), jamais une adoption.
## Création JIT (juste-à-temps)
À la première connexion draco d'une identité sans correspondance :
- **nom d'utilisateur** : nom affiché des claims slugifié
(`Alice Exemple` → `alice-exemple`), libre ou suffixé ;
repli `draco-<sub>` si le nom est inexploitable ;
- **nom d'affichage** : le `name` des claims ;
- **rôle** : `member` (utilisateur standard) — jamais admin ;
- **mot de passe local** : secret aléatoire, haché (PBKDF2 comme
tous les comptes), **jamais divulgué** — l'accès normal passe
par draco. Un administrateur peut ensuite poser un mot de
passe local depuis `/admin` (bouton « Réinitialiser ») si un
accès local est voulu ;
- **espace** : le compte rejoint l'espace Principal comme membre,
comme tout utilisateur créé par l'administration ;
- le geste est journalisé (`draco.jit` dans le journal
d'administration).
## Liaison par l'administrateur (comptes préexistants)
Pour associer un compte Kanbante existant à une identité draco
sans attendre un premier passage :
1. Se connecter à Kanbante avec un compte **admin**.
2. Ouvrir **Administration** (`/admin`), section **Utilisateurs**.
3. Dans la colonne **Draco (sub)** du compte visé, saisir le
`sub` draco de l'identité (l'identifiant du compte de suite
chez l'émettrice, ObsiGate) et cliquer **Lier**.
4. La liaison est immédiate et journalisée (`draco.link.admin`).
Le bouton **Délier** de la même colonne la retire
(`draco.unlink.admin`).
Gardes : un `sub` déjà lié à un autre compte est refusé avec un
message d'erreur (délier l'autre compte d'abord). L'administrateur
peut lier **son propre** compte — c'est la voie manuelle pour le
compte d'amorçage si le premier passage ne l'a pas lié.
Chaque utilisateur peut aussi voir sa propre liaison dans
**Mon profil → Liaison draco (SSO)** et la délier lui-même.
> Le registre matriciel des rôles par application côté Hub
> (carte HUB-19) lit cette correspondance ; la 0.46.0 ne livrait
> que le côté Kanbante, qui reste la source de vérité locale.
> La consommation du registre par Kanbante est décrite à la
> section suivante (0.46.2).
## Rôles du Hub (HUB-19, 0.46.2)
Le registre des rôles du Hub (matrice par application, contrat
D3(a)) est consulté par `app/hub_roles.py` — portage du pilote
ShaarIt 0.7.0 : `GET {hub}/api/roles/kanbante`, en-tête Cookie
complet de la requête relayé, délai 2 s, cache mémoire par
`sub` de 60 s avec horodatage posé avant l'appel (un échec
conserve l'ancien cache et ne déclenche pas de rafale). L'URL
du Hub est celle de la barre commune (`resoudre_urls_draco`).
Toute erreur rend un rôle **inconnu**, qui vaut toujours
« comportement actuel » — jamais un verrouillage nouveau.
Effets, pour les seules sessions **établies par le pont
draco** :
1. **Pont `/auth/draco`** : la consultation précède la
liaison, la résolution de la correspondance et la création
JIT. Rôle connu `aucun` → refus explicite (403, en
français) : aucune session ouverte par draco pour ce
`sub`, aucun compte créé. La session que le pont ouvre
porte un marqueur « draco » couvert par la signature du
cookie (`user.expiry.draco.hmac`).
2. **`/admin`** (et ses actions, même garde
`_require_admin`) : session établie par draco + rôle du
registre connu ⇒ il faut `administrateur` dans le
registre, **en plus** du rôle local. Rôle inconnu ou
session native ⇒ règles locales, inchangées.
Les autres accès ne sont pas draco et ne sont pas touchés :
session native préexistante, connexion native (mot de
passe), Basic d'environnement, jetons `kbt_`. Ni les droits
internes ni la réconciliation par étiquettes ne changent.
## Surfaces voisines
- **`GET /api/search`** (« Rechercher partout ») : le cookie draco
n'y est plus accepté « en tant qu'opérateur ». Il faut que le
`sub` désigne un compte (correspondance existante ou compte
d'amorçage lié au passage). Aucune création JIT sur cette
surface : la création a lieu à la première vraie connexion, par
le pont. Les autres modes d'accès à `/api/search` (session,
Basic, jeton `kbt_`) sont inchangés.
- **Désactivation** : désactiver un compte dans `/admin` coupe
tous ses accès — session native, pont draco (la correspondance
résout à « refusé ») et jetons `kbt_`. Réactiver le compte
rétablit la correspondance telle quelle.
## Non-régressions garanties
- **Jetons personnels `kbt_`** : création, usage sur `/mcp` et
`/api/`, révocation — inchangés ; un jeton n'ouvre toujours pas
l'interface web.
- **Sessions propres Kanbante** et **connexion native**
(formulaire, mot de passe de la base) : inchangées, y compris
la liaison automatique à la première visite doublement
connectée.
- **Authentification Basic** (identifiants d'environnement, pour
les scripts) : inchangée.
- **Pont inerte sans `DRACO_SESSION_SECRET`** : sans secret
configuré, `/auth/draco` renvoie au login, comme avant.
- La réconciliation Kanbante/Gitea par étiquettes (adoption d'une
colonne seulement si exactement une étiquette `kanban:*` est
reconnue) n'est pas touchée par cette carte.
+14 -80
View File
@@ -7,39 +7,14 @@
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
Alertes icône seule.
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
v0.2.0 : nouvel onglet « ShaarIt » (data-app="shaarit", --app émeraude
#059669, logo logos/shaarit.svg via {{LOGO_SHAARIT}}) avant l'onglet
agent ; GARDE data-theme : les blocs prefers-color-scheme ne
s'appliquent que si <html> n'a AUCUN attribut data-theme
(html:not([data-theme])) — avant ce correctif, un hôte ayant choisi
le clair via data-theme="light" héritait quand même du sombre
système (piège constaté dans les copies vendangées de
Kanbante/Flowdeck ; les re-vendanges de la v0.2.0 le corrigent).
v0.3.0 (HUB-1) : aucun style nouveau — les liens du Hub du menu
compte (Télécharger, À Propos, Aides, Profil) réutilisent
.draco-menu-item (déjà ancre-compatible : text-decoration none)
et le séparateur .draco-menu-sep du menu des applications.
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
items du menu compte (SVG inline construit par le JS) : 18 px,
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
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
(aucun attribut data-theme 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: #c00000; --danger: #cf4444;
--agent: #c2437e; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
@@ -47,12 +22,8 @@
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
/* Garde data-theme (v0.2.0) : le sombre système ne s'applique QUE si
* l'hôte n'a posé aucun data-theme sur <html>. Sans cette garde, un
* hôte en data-theme="light" sur un système sombre affichait la barre
* sombre malgré son choix explicite. */
@media (prefers-color-scheme: dark) {
html:not([data-theme]) .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
.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;
@@ -66,15 +37,7 @@
.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:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { line-height: 0; }
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
fond transparent — noire sur barre claire (filtre), blanche sur
barre foncée (filtre annulé par les règles thème ci-dessous). */
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
@media (prefers-color-scheme: dark) {
html:not([data-theme]) .draco-bar .draco-brand-mark img { filter: none; }
}
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
.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 {
@@ -89,7 +52,7 @@
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 232px; padding: 6px;
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);
}
@@ -101,20 +64,6 @@
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
/* Item conditionnel masqué (v0.6.0 : « Administration » tant que
l'hôte n'a pas affirmé le statut d'administrateur) — le
display:flex ci-dessus écraserait l'attribut hidden sans cette
règle, même patron que .draco-compte-menu[hidden]. */
.draco-menu-item[hidden] { display: none; }
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
libellé dans le DOM, replacées à gauche par order: -1. */
.draco-menu-icone {
order: -1; flex: none; display: inline-flex; align-items: center;
width: 18px; height: 18px; color: var(--ink-2);
}
.draco-menu-icone svg { display: block; width: 18px; height: 18px; }
.draco-menu-item:hover .draco-menu-icone,
.draco-menu-item:focus-visible .draco-menu-icone { color: var(--ink); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
@@ -145,13 +94,6 @@
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
/* Icônes logos (2026-10-29, corrigé le même jour) : l'image du logo
* fourni, telle quelle, remplit la tuile 30 px — jamais de glyphe
* texte, jamais de redessin. Le fond de la tuile est transparent :
* les logos PNG portent déjà leur fond (dégradé), le SVG TikTak
* reste sur le fond de la barre. */
.draco-apptab-ico { background: transparent; }
.draco-apptab-ico img { width: 30px; height: 30px; display: block; border-radius: 9px; object-fit: cover; }
.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); }
@@ -189,23 +131,18 @@
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; height: 38px;
padding: 0 9px; border-radius: 10px; border: 1px solid var(--line);
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;
}
/* Bouton avatar Spark flottant — bas-centre, ouvre le panneau Spark
de l'hôte via data-act="spark" (délégation globale existante).
La console Spark ne l'inclut pas : son fragment est propre. */
.draco-spark-flottant {
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
z-index: 65; width: 64px; height: 64px; padding: 0; border: 0;
border-radius: 50%; background: transparent; cursor: pointer;
box-shadow: 0 8px 28px rgb(0 0 0 / .45);
/* 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-flottant img { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: block; box-sizing: border-box; border: 3px solid var(--agent); }
.draco-spark-flottant:hover { transform: translateX(-50%) scale(1.05); }
.draco-spark-flottant:focus-visible { outline: 2px solid var(--agent); outline-offset: 3px; }
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
@@ -232,7 +169,7 @@
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd { display: none; }
.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; }
@@ -256,9 +193,6 @@
.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"],
/* v0.2.0 : sur ShaarIt elle-même (data-current="shaarit"), c'est
l'onglet ShaarIt qui reste visible en mobile. */
.draco-bar[data-current="shaarit"] .draco-apptab[data-app="shaarit"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
+188 -449
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<!-- ShaarIt — signet (bookmark) blanc sur fond émeraude #059669,
couleur signature de l'app dans la barre (#059669, distincte du
teal Kanbante #0d9488). Fond arrondi comme les logos PNG 180 px
des autres apps ; géométrie simple, aucun redessin d'un logo
existant : le signet EST le symbole de l'app (bookmarks). -->
<rect x="8" y="8" width="224" height="224" rx="48" fill="#059669"/>
<path d="M 82 52 L 158 52 L 158 190 L 120 152 L 82 190 Z" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<!-- TikTak — deux arcs (les deux temps), coche-flèche. Bleu #0076fb, orange #fc750c.
Géométrie mesurée sur le visuel validé par Bruno (variante B). -->
<path d="M 38.2 101.1 A 84 84 0 0 1 201.8 101.1" fill="none" stroke="#0076fb" stroke-width="28" stroke-linecap="round"/>
<path d="M 201.5 140.3 A 84 84 0 0 1 38.5 140.3" fill="none" stroke="#fc750c" stroke-width="28" stroke-linecap="round"/>
<polyline points="80,129 106,146 163,84" fill="none" stroke="#0076fb" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 128 80 L 163 84 L 167 122" fill="none" stroke="#0076fb" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 760 B

-41
View File
@@ -176,47 +176,6 @@
if (e.key === 'Escape') closeMenus();
});
/* ── Copier l'URL du projet (#44) ─────────────────────── */
function showCopyToast(message) {
var toast = document.createElement('div');
toast.className = 'kanbante-toast';
toast.setAttribute('role', 'status');
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(function () { toast.remove(); }, 2600);
}
function copyTextFallback(text) {
var area = document.createElement('textarea');
area.value = text;
area.style.cssText = 'position:fixed;opacity:0;pointer-events:none;';
document.body.appendChild(area);
area.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
area.remove();
return ok;
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).then(
function () { return true; },
function () { return copyTextFallback(text); }
);
}
return Promise.resolve(copyTextFallback(text));
}
document.querySelectorAll('[data-copy-url]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var url = btn.getAttribute('data-copy-url') || '';
if (!url) return;
copyText(url).then(function (ok) {
closeMenus();
showCopyToast(ok ? 'URL du projet copiée' : 'La copie a échoué');
});
});
});
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
partagé avec /forecast et /health (0.19.0). */
})();
+26 -397
View File
@@ -8,56 +8,7 @@
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux ») ;
* v0.2.0 : 5e application ShaarIt — clé urls.shaarit, défaut officiel
* https://shaarit.dracodev.net/, dérivation -dev shaarit-dev (même
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
* (data-app="shaarit", après Kanbante, avant agent) est repris
* automatiquement par la construction du menu de la marque.
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
* sœurs. Le menu compte gagne trois vrais liens (patron
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
* celui du Hub). « Déconnexion » reste l'action relayée
* (data-compte-act="deconnexion" / onAction), inchangée.
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
* sans étape « href rendu » (voir « URL » ci-dessous).
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
* reçoit un glyphe SVG inline dans le style des icônes du rail de
* la console (contour, trait fin régulier, currentColor, aucun
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
* la barre apprend la préférence de thème du profil du Hub
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
* local court et repli intégral sur le comportement actuel de
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
* v0.6.0 (demande de Bruno, 2026-11-06) : le menu compte gagne un
* cinquième lien Hub, « Administration » → <hub>/admin (le Hub y
* redirige vers /admin/utilisateurs), placé après le séparateur,
* avant « Déconnexion », avec son icône monoline (curseurs de
* réglage). AFFICHAGE CONDITIONNEL : l'entrée n'apparaît que si
* l'hôte AFFIRME que le compte courant est administrateur de
* suite — cfg.admin === true à la configuration, ou
* DRACO_BAR.setAdmin(true) quand l'hôte l'apprend après coup
* (patron setUrls). L'hôte ne passe le drapeau que s'il l'a établi
* depuis SA source d'autorité (rôle global du compte chez
* ObsiGate, niveau du registre des rôles du Hub pour son app…) ;
* à défaut de certitude, pas de drapeau et l'entrée reste masquée.
* La barre ne devine jamais : le jeton draco ne porte aucun rôle
* (claims minimaux, par design). La vraie protection reste la
* garde serveur du Hub (/admin/*), pas ce masquage d'affichage.
* Voir le README, « Administration » — contrat de visibilité.
* « Menu des applications », « Focus » et « Écouteurs globaux »).
*
* 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
@@ -70,20 +21,13 @@
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* admin: true, // (v0.6.0, optionnel)
* // administrateur de suite ÉTABLI par l'hôte : démasque
* // l'entrée « Administration » du menu compte. Absent ou
* // non-strictement-true → entrée masquée. Peut aussi être
* // posé après coup via DRACO_BAR.setAdmin(true).
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* shaarit: "https://shaarit.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/",
* hub: "https://hub.dracodev.net/"
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
@@ -110,19 +54,10 @@
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre, v0.2.0 : ObsiGate,
* Flowdeck, TikTak, Kanbante, ShaarIt, Console Spark) : elle suit
* donc l'étape « href
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls. Même résolution pour shaarit (v0.2.0) :
* explicite > dérivation -dev shaarit-dev > href rendu
* ({{URL_SHAARIT}}) > défaut officiel.
* Cas hub (v0.3.0) : PAS d'onglet, donc pas d'étape « href rendu » —
* explicite (DRACO_BAR_CONFIG.urls.hub, alimenté par le jeton
* {{URL_HUB}} remplacé côté serveur par l'hôte) > dérivation -dev
* hub-dev sur hôte -dev > défaut officiel
* https://hub.dracodev.net/. La base résolue sert les quatre liens
* du menu compte (telecharger, a-propos, aide, profil).
* la marque et DRACO_BAR.urls.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
@@ -197,14 +132,6 @@
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
* Menu compte (v0.3.0) — Profil n'est PLUS relayé : c'est un lien
* vers <hub>/profil, comme Télécharger / À Propos / Aides (liens
* Hub, data-hub-link, href resynchronisés à chaque ouverture via
* refreshCompteHrefs / refreshMenuHrefs). Seule « Déconnexion »
* reste relayée à l'hôte (data-compte-act="deconnexion"), inchangée.
* Les hôtes qui traitaient onAction("profil") (Kanbante → /profile)
* voient ce comportement remplacé par le lien Hub — décision du
* plan Hub : un seul Profil, celui du Hub.
*/
(function () {
"use strict";
@@ -217,10 +144,8 @@
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
shaarit: "https://shaarit.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/",
hub: "https://hub.dracodev.net/"
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
@@ -232,14 +157,12 @@
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
shaarit: "https://shaarit-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/",
hub: "https://hub-dev.dracodev.net/"
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante", shaarit: "ShaarIt"
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
@@ -295,131 +218,6 @@
}
var resolvedUrls = resolveUrls();
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
* La préférence de thème de la suite vit dans le profil du Hub :
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
* authentifié par le cookie draco (credentials: "include").
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
* défaut officiel) — jamais un nom figé de plus.
* Application par le mécanisme data-theme existant des hôtes :
* "sombre" → data-theme="dark", "clair" → data-theme="light",
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
* html:not([data-theme]) du CSS de la barre comme des hôtes).
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
* propre doit suivre (icônes et libellés, feuilles de style,
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
* s'initialise après la barre lit la même information dans
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
* Ordre d'application : 1) le cache local FRAIS est appliqué de
* façon SYNCHRONE à l'initialisation de la barre (script en defer
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
* a changé. À la toute première visite (aucun cache), un bref
* flash du thème local de l'hôte reste possible le temps du fetch :
* c'est le prix du repli — l'état affiché transitoire est le thème
* que l'hôte aurait de toute façon choisi seul, jamais un état
* cassé, et les visites suivantes sont servies par le cache.
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
* jamais le choix local de l'hôte.
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
* existant. Le comportement actuel de l'hôte (son localStorage,
* puis le système) reste alors exactement le maître, comme avant
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
* fetch) ; elle ne ré-applique ensuite que sur changement du
* schéma système, et seulement tant que la préférence résolue est
* "systeme". */
var THEME_CACHE_CLE = "draco-theme-hub";
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
function themeSystemeEstSombre() {
try {
return !!(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) { return false; }
}
function lireCacheTheme() {
try {
var brut = window.localStorage &&
window.localStorage.getItem(THEME_CACHE_CLE);
if (!brut) return null;
var c = JSON.parse(brut);
if (!c || typeof c.ts !== "number") return null;
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
return c.theme;
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
return null;
}
function ecrireCacheTheme(theme) {
try {
if (window.localStorage) {
window.localStorage.setItem(THEME_CACHE_CLE,
JSON.stringify({ theme: theme, ts: Date.now() }));
}
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
}
function appliquerThemeCommun(theme) {
var root = document.documentElement;
if (!root) return;
if (theme === "sombre" || theme === "clair") {
themeMode = theme === "sombre" ? "dark" : "light";
root.setAttribute("data-theme", themeMode);
} else if (theme === "systeme") {
themeMode = themeSystemeEstSombre() ? "dark" : "light";
root.removeAttribute("data-theme");
} else {
return;
}
themePref = theme;
if (window.DRACO_BAR) {
window.DRACO_BAR.themePref = themePref;
window.DRACO_BAR.themeMode = themeMode;
}
try {
document.dispatchEvent(new CustomEvent("draco:theme",
{ detail: { theme: themePref, mode: themeMode } }));
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
}
function initThemeCommun() {
var enCache = lireCacheTheme();
if (enCache) appliquerThemeCommun(enCache);
/* Suivi du schéma système tant que la préférence est "systeme" :
* sans data-theme, le CSS suit tout seul ; l'événement est
* re-dispatché pour les hôtes qui matérialisent le mode. */
try {
var mq = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", function () {
if (themePref === "systeme") appliquerThemeCommun("systeme");
});
}
} catch (e) { /* pas de matchMedia : pas de suivi */ }
if (typeof window.fetch !== "function") return;
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
"api/profil/theme";
window.fetch(url, { credentials: "include" }).then(function (resp) {
if (!resp || !resp.ok) return null;
return resp.json();
}).then(function (data) {
if (!data) return;
var t = data.theme;
if (t === "sombre" || t === "clair" || t === "systeme") {
ecrireCacheTheme(t);
appliquerThemeCommun(t);
}
/* theme null ou inconnu : repli intégral, rien n'est touché. */
}).catch(function () { /* Hub injoignable : repli intégral */ });
}
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -498,28 +296,6 @@
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
/* v0.3.0 : les liens du Hub (menu compte) sont resynchronisés
* eux aussi à l'ouverture d'un menu, pour refléter setUrls et
* la dérivation -dev même s'ils ont été construits avant. */
refreshCompteHrefs();
}
/* Liens du Hub dans le menu compte (v0.3.0) : chemin → libellé.
* « Profil » est le quatrième lien (vers <hub>/profil) ; il
* remplace l'ancien item relayé « profil ». */
var HUB_LINKS = [
["telecharger", "Télécharger"],
["a-propos", "À Propos"],
["aide", "Aides"],
["profil", "Profil"]
];
function hubHref(path) {
return withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) + path;
}
function refreshCompteHrefs() {
if (!compteMenu) return;
compteMenu.querySelectorAll("[data-hub-link]").forEach(function (a) {
a.setAttribute("href", hubHref(a.getAttribute("data-hub-link")));
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
@@ -589,15 +365,11 @@
if (badge) a.appendChild(badge.cloneNode(true));
appsMenu.appendChild(a);
});
/* Entrée « Console Spark » (#70) : après les onglets d'apps
* (ObsiGate…ShaarIt, v0.2.0), vers la console
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; son icône CLONE celle de l'onglet agent
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
* autres éléments de la barre n'est pas changé ici. */
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
@@ -607,18 +379,10 @@
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco;
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
if (agentIco) {
sparkIco = agentIco.cloneNode(true);
} else {
sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#d21f2c");
sparkIco.setAttribute("data-icon", "agent");
sparkIco.textContent = "✦";
}
var sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#c2437e");
sparkIco.textContent = "✦";
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
@@ -642,101 +406,10 @@
if (!appsMenu.hidden) lastOpener = brand;
}
/* Icônes monoline du menu compte (v0.4.0) — glyphes contour
* dans le style des icônes du rail de la console : viewBox 24,
* stroke currentColor, remplissage nul, extrémités rondes.
* Chaque glyphe est une liste de primitives [balise, attributs]
* construites en DOM (jamais d'innerHTML), comme le reste du
* fichier. Le libellé reste le nœud texte de l'item ; l'icône,
* appendue après, est replacée à gauche par le CSS (order: -1
* sur .draco-menu-icone, l'item étant déjà en flex). */
var MENU_ICONES = {
/* Flèche vers le bas dans un plateau. */
"telecharger": [
["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["polyline", { points: "7 10 12 15 17 10" }],
["line", { x1: "12", y1: "15", x2: "12", y2: "3" }]
],
/* Cercle d'information. */
"a-propos": [
["circle", { cx: "12", cy: "12", r: "9" }],
["line", { x1: "12", y1: "11", x2: "12", y2: "16" }],
["line", { x1: "12", y1: "8", x2: "12.01", y2: "8" }]
],
/* Cercle d'interrogation. */
"aide": [
["circle", { cx: "12", cy: "12", r: "9" }],
["path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }]
],
/* Personne : tête + épaules. */
"profil": [
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
["circle", { cx: "12", cy: "7", r: "4" }]
],
/* Curseurs de réglage (trois axes, trois boutons) — v0.6.0. */
"admin": [
["line", { x1: "4", y1: "21", x2: "4", y2: "14" }],
["line", { x1: "4", y1: "10", x2: "4", y2: "3" }],
["line", { x1: "12", y1: "21", x2: "12", y2: "12" }],
["line", { x1: "12", y1: "8", x2: "12", y2: "3" }],
["line", { x1: "20", y1: "21", x2: "20", y2: "16" }],
["line", { x1: "20", y1: "12", x2: "20", y2: "3" }],
["line", { x1: "1", y1: "14", x2: "7", y2: "14" }],
["line", { x1: "9", y1: "8", x2: "15", y2: "8" }],
["line", { x1: "17", y1: "16", x2: "23", y2: "16" }]
],
/* Flèche sortant d'un cadre (porte). */
"deconnexion": [
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
["polyline", { points: "16 17 21 12 16 7" }],
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
span.appendChild(svg);
return span;
}
/* Visibilité de l'entrée « Administration » (v0.6.0) : vraie
* seulement si l'hôte l'a AFFIRMÉE — cfg.admin === true à la
* configuration, ou DRACO_BAR.setAdmin(true) après coup. La
* barre ne déduit jamais ce statut (le jeton draco ne porte
* aucun rôle) ; à défaut, l'entrée reste masquée et la garde
* serveur du Hub reste la vraie protection. */
var compteAdmin = cfg.admin === true;
var adminItem = null;
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
* d'identité, puis les liens du Hub (Télécharger, À Propos,
* Aides, Profil — vrais <a href>, même patron que le menu des
* applications), un séparateur, puis « Déconnexion » relayée à
* l'hôte. L'en-tête et les href sont rafraîchis à chaque
* ouverture, pour refléter un title posé après coup par l'hôte
* (ex. ObsiGate, qui remplit l'avatar depuis /api/auth/me après
* chargement) et la résolution d'URL la plus récente. */
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
@@ -747,44 +420,15 @@
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
HUB_LINKS.forEach(function (pair) {
var a = document.createElement("a");
a.className = "draco-menu-item";
a.href = hubHref(pair[0]);
a.setAttribute("data-hub-link", pair[0]);
a.setAttribute("role", "menuitem");
a.textContent = pair[1];
a.appendChild(menuIcone(pair[0]));
compteMenu.appendChild(a);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
compteMenu.appendChild(sep);
/* « Administration » (v0.6.0) : cinquième lien Hub
* (<hub>/admin, où le Hub redirige vers /admin/utilisateurs).
* data-hub-link="admin" : son href est resynchronisé par
* refreshCompteHrefs / setUrls comme les quatre autres liens.
* Toujours construit ; masqué tant que l'hôte n'a pas affirmé
* le statut d'administrateur (compteAdmin). */
var adm = document.createElement("a");
adm.className = "draco-menu-item";
adm.href = hubHref("admin");
adm.setAttribute("data-hub-link", "admin");
adm.setAttribute("role", "menuitem");
adm.textContent = "Administration";
adm.appendChild(menuIcone("admin"));
adm.hidden = !compteAdmin;
compteMenu.appendChild(adm);
adminItem = adm;
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", "deconnexion");
b.setAttribute("role", "menuitem");
b.textContent = "Déconnexion";
b.appendChild(menuIcone("deconnexion"));
compteMenu.appendChild(b);
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
@@ -792,8 +436,6 @@
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
if (adminItem) adminItem.hidden = !compteAdmin;
refreshCompteHrefs();
}
function toggleCompteMenu(force) {
buildCompteMenu();
@@ -885,23 +527,11 @@
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
themePref: null, themeMode: null,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
refreshCompteHrefs();
},
/* Administration (v0.6.0) : l'hôte pose le drapeau quand il a
* ÉTABLI que le compte courant est administrateur de suite
* (sa source d'autorité — voir l'en-tête et le README). Toute
* valeur autre que le booléen true masque l'entrée. */
setAdmin: function (v) {
compteAdmin = v === true;
if (adminItem) adminItem.hidden = !compteAdmin;
}
};
@@ -910,7 +540,6 @@
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
initThemeCommun();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
-63
View File
@@ -34,71 +34,8 @@
if (window.innerWidth > 900) closeDrawer();
});
/* Bascule Dark/Light (#52) : le choix est mémorisé et
l'amorce de _header.html l'a déjà appliqué avant le rendu.
Ici, on ne fait que synchroniser le libellé et le clic. */
var themeBtn = document.getElementById('theme-toggle');
function syncThemeBtn(theme) {
if (!themeBtn) return;
var light = theme === 'light';
/* Bouton dans la section Administration (#55) : icône et
libellé sont des <span> séparés ; l'ancien bouton de
l'en-tête, lui, n'avait qu'un texte. */
var ico = themeBtn.querySelector('[data-theme-ico]');
var lbl = themeBtn.querySelector('[data-theme-label]');
if (ico) ico.textContent = light ? '☾' : '☀';
if (lbl) lbl.textContent = light ? 'Mode sombre' : 'Mode clair';
if (!ico && !lbl) {
themeBtn.textContent = light ? '☾ Mode sombre' : '☀ Mode clair';
}
themeBtn.setAttribute('aria-pressed', light ? 'true' : 'false');
}
if (themeBtn) {
syncThemeBtn(document.documentElement.getAttribute('data-theme') || 'dark');
themeBtn.addEventListener('click', function () {
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('kanbante-theme', next); } catch (e) { /* non-fatal */ }
syncThemeBtn(next);
});
}
/* Thème commun de la suite (HUB-18, fusion #132) : la barre
commune (v0.5.0) applique la préférence du Hub à l'ouverture —
directement sur data-theme depuis son cache, et via
l'événement « draco:theme » quand le fetch aboutit. Ici, on
resynchronise le bouton de bascule avec le mode effectif.
Le choix local « kanbante-theme » n'est PAS écrasé : la
préférence du Hub initialise l'ouverture, le bouton reste le
maître pour la session (son clic mémorise comme avant).
Repli : Hub muet → aucun événement, l'amorce de _header.html
(choix local, défaut sombre) reste seule maître, comme avant.
La barre (defer) s'exécute avant nav.js : sa préférence déjà
résolue depuis son cache est aussi reprise au démarrage. */
function appliquerThemeCommun(mode) {
document.documentElement.setAttribute('data-theme', mode);
syncThemeBtn(mode);
}
document.addEventListener('draco:theme', function (e) {
var mode = e && e.detail && e.detail.mode;
if (mode === 'dark' || mode === 'light') appliquerThemeCommun(mode);
});
if (window.DRACO_BAR && (window.DRACO_BAR.themeMode === 'dark' || window.DRACO_BAR.themeMode === 'light')) {
appliquerThemeCommun(window.DRACO_BAR.themeMode);
}
/* En mobile, le bouton de repli dans le tiroir FERME le tiroir :
replier un tiroir de 264 px n'a pas de sens, et avant ce
correctif il basculait l'état desktop (tiroir 68 px). */
var sideLinks = document.querySelectorAll('.side-link');
sideLinks.forEach(function (link) {
link.addEventListener('click', function () {
if (window.innerWidth <= 900) closeDrawer();
});
});
if (collapseBtn) {
collapseBtn.addEventListener('click', function () {
if (window.innerWidth <= 900) { closeDrawer(); return; }
var collapsed = document.body.classList.toggle('sidebar-collapsed');
try {
localStorage.setItem('kanbante-sidebar', collapsed ? 'collapsed' : 'open');
+9 -39
View File
@@ -2,13 +2,11 @@
* chantier 2+3 : onglets, « Tout voir », focus).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante, ShaarIt) : la configuration
* vient des attributs data-* de #recherche, rendus côté serveur par
* l'hôte. Source canonique : dracodev-commons, ui/recherche/
* (contrat : contrats/recherche-federee.md).
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* ShaarIt, Console Spark. « Tout » = les 5 sections groupées, chaque liste
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
* de cette application (aucune troncature côté client — la requête
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
@@ -48,11 +46,6 @@
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - ShaarIt : GET {base}/api/search?q=
* -> { links: [{ id, title, url, description, tags, shorturl,
* private }] }
* Lien profond (contrat liens-profonds) : {base}shaare/<shorturl>.
* Recherche native adressable : {base}?q=<q> (page d'accueil).
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* (agent-commun, authentifié par le cookie SSO draco_session) ->
* { q, domain, limite_par_type, resultats[], par_type,
@@ -94,9 +87,8 @@
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 5 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68,
* ShaarIt via la fédération v0.2.1 de commons)
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* et par GET /api/v1/recherche de la console (identity() accepte
* draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
@@ -117,14 +109,12 @@
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
shaarit: "https://shaarit.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
shaarit: "https://shaarit-dev.dracodev.net/",
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
@@ -135,7 +125,6 @@
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
{ key: "shaarit", label: "ShaarIt" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
@@ -156,7 +145,7 @@
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "shaarit", "console"];
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
@@ -238,7 +227,7 @@
return TABS.indexOf(v) !== -1 ? v : "all";
}
/* Sections visibles pour un onglet. « Tout » = les 5 sections
/* Sections visibles pour un onglet. « Tout » = les 4 sections
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
function visibleKeys(tab) {
tab = parseTab(tab);
@@ -267,7 +256,7 @@
* Chargement -> "…", ok/vide -> total ("0" honnête),
* erreur/indisponible/inactif -> "" (pas de faux 0).
* Tout : "…" tant qu'une section charge, sinon somme des totaux
* connus, "" si les 5 sections sont en erreur/inactives. */
* connus, "" si les 4 sections sont en erreur/inactives. */
function tabCounts(states) {
states = states || {};
var out = {};
@@ -378,22 +367,6 @@
};
});
}
if (app === "shaarit") {
return cut(data.links || []).map(function (l) {
var tags = (l.tags || []).filter(function (t) { return !!t; });
var ctx = "Bookmark";
if (l.private) ctx += " · privé";
if (tags.length) ctx += " · " + tags.join(", ");
return {
title: l.title || l.url || "Sans titre",
snippet: snippetOf(l.description || ""),
context: ctx,
/* Lien profond canonique (contrats/liens-profonds.md) ;
* sans shorturl, repli sur l'URL du bookmark lui-même. */
url: l.shorturl ? base + "shaare/" + l.shorturl : l.url || null,
};
});
}
return [];
}
@@ -482,16 +455,13 @@
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
if (app === "shaarit") {
return base + "?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante" || app === "shaarit") {
if (app === "tiktak" || app === "kanbante") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
+1 -1
View File
@@ -75,7 +75,7 @@
var item = document.createElement('span');
item.className = 'repo-item';
item.setAttribute('role', 'option');
var star = repo.starred ? '★ ' : '';
var star = repo.starred ? '⭐ ' : '';
var date = repo.updated_at ? repo.updated_at.slice(0, 10) : '';
var nameEl = document.createElement('span');
nameEl.className = 'repo-item-name';
+3 -3
View File
@@ -31,7 +31,7 @@
var data = await res.json();
if (!data.ok) throw new Error(data.error || 'échec');
btn.setAttribute('data-starred', want ? '1' : '0');
btn.textContent = want ? '★' : '☆';
btn.textContent = want ? '⭐' : '☆';
btn.title = want ? 'Désépingler (étoile Gitea)' : 'Épingler (étoile Gitea)';
var card = btn.closest('.project-card');
if (card) {
@@ -44,9 +44,9 @@
refreshPinnedVisibility();
}
} catch (err) {
btn.textContent = '!';
btn.textContent = '⚠️';
setTimeout(function () {
btn.textContent = btn.getAttribute('data-starred') === '1' ? '★' : '☆';
btn.textContent = btn.getAttribute('data-starred') === '1' ? '⭐' : '☆';
}, 1600);
} finally {
btn.disabled = false;
+5 -21
View File
@@ -61,18 +61,8 @@ items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
/* normalize ShaarIt (commons v0.2.1) : lien profond shaare/<shorturl>,
* contexte privé + tags, repli sur l'URL du bookmark sans shorturl */
items = rp.normalize("shaarit", { links: [{ title: "Yandex", url: "https://yandex.com", description: "moteur", tags: ["recherche", "web"], shorturl: "xYz9", private: true }] }, "https://shaarit-dev.dracodev.net/", "recherche");
ok(items[0].title === "Yandex" && items[0].url === "https://shaarit-dev.dracodev.net/shaare/xYz9", "lien profond ShaarIt (shaare/<shorturl>)");
ok(items[0].context === "Bookmark · privé · recherche, web", "contexte ShaarIt (privé + tags)");
ok(items[0].snippet === "moteur", "extrait ShaarIt = description");
items = rp.normalize("shaarit", { links: [{ title: "", url: "https://exemple.fr", tags: [], shorturl: "" }] }, "https://shaarit.dracodev.net/", "x");
ok(items[0].url === "https://exemple.fr" && items[0].title === "https://exemple.fr", "ShaarIt sans shorturl : repli URL + titre");
/* nativeSearchUrl */
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
ok(rp.nativeSearchUrl("shaarit", "https://shaarit-dev.dracodev.net/", "q") === "https://shaarit-dev.dracodev.net/?q=q", "repli ShaarIt = accueil ?q=");
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
@@ -80,17 +70,15 @@ ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "http
/* parseTab */
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
ok(rp.parseTab("shaarit") === "shaarit", "onglet shaarit valide");
ok(rp.parseTab("console") === "console", "onglet console valide");
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
/* visibleKeys : Tout = les 5 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "Tout = 5 sections, pas la console");
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
ok(JSON.stringify(rp.visibleKeys("shaarit")) === JSON.stringify(["shaarit"]), "onglet shaarit = sa seule section");
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "onglet inconnu -> Tout");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
@@ -103,11 +91,9 @@ let c = rp.tabCounts({
flowdeck: { status: "ok", total: 3 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "ok", total: 2 },
console: { status: "idle", total: 0 },
});
ok(c.obsigate === "…", "compteur … pendant le chargement");
ok(c.shaarit === "2", "compteur shaarit");
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
ok(c.all === "…", "Tout = … tant qu'une section charge");
@@ -116,20 +102,18 @@ c = rp.tabCounts({
flowdeck: { status: "ok", total: 2 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "ok", total: 1 },
console: { status: "ok", total: 4 },
});
ok(c.all === "8", "Tout = somme des totaux connus (5 sections)");
ok(c.all === "7", "Tout = somme des totaux connus");
ok(c.console === "4", "compteur console");
c = rp.tabCounts({
obsigate: { status: "error", total: 0 },
flowdeck: { status: "error", total: 0 },
tiktak: { status: "error", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.all === "", "Tout vide si les 5 sections sont en erreur");
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
/* buildSearchUrl : état onglet partageable */
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
-25
View File
@@ -1,25 +0,0 @@
"""Panneau d'alertes unifiées (Fusion #69) et thèmes.
Le CSS neutre draco-alertes.css attend des variables d'hôte
(--bg-primary, --text-dim, --text-tertiary). Kanbante ne les
définissait pas : le panneau retombait sur ses replis sombres,
illisible en thème clair (retour de Bruno, 2026-10-30).
L'hôte les branche sur ses propres jetons, qui suivent
html[data-theme].
"""
def test_alertes_variables_hote_branchees(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert ".draco-alertes-box {" in css
assert "--bg-primary: var(--surface);" in css
assert "--text-dim: var(--text-2);" in css
assert "--text-tertiary: var(--text-3);" in css
def test_alertes_css_neutre_inchange(client, fake_gitea):
# Le fichier partagé n'est pas modifié côté Kanbante :
# il continue de consommer les variables d'hôte.
css = client.get("/static/css/draco-alertes.css").text
assert "background: var(--bg-primary, #1f1f1f);" in css
assert "color: var(--text-dim, #999);" in css
-113
View File
@@ -1,113 +0,0 @@
"""Barre commune v0.2.0 (phase 3 ShaarIt) — re-vendange 0.45.6.
- CSS/JS vendangés VERBATIM depuis dracodev-commons @70c837a
(ui/barre-apps/barre-apps.css / .js) ; logo shaarit.svg idem.
- GARDE data-theme (changement de comportement visible) : avant
la v0.2.0, le CSS vendangé appliquait le sombre système dès
``prefers-color-scheme: dark``, même avec un thème clair
explicite (html[data-theme="light"]) ; la v0.2.0 conditionne
les deux blocs à ``html:not([data-theme])``.
- Onglet ShaarIt : présent dans le rendu, APRÈS Kanbante et
AVANT l'agent, URL résolue par le même mécanisme que les
autres apps (explicite DRACO_SHAARIT_URL > hôte de la
requête > dérivation par l'agent > défaut officiel).
"""
import hashlib
from pathlib import Path
R = Path(__file__).resolve().parents[1]
SHA256_CSS_V020 = "abc0c3b2a282566013bd121b0c46b133700f20be78cf44128bebdf19b7e05fab"
SHA256_JS_V020 = "24efe399823eed21add535c081ea153ae97afe128744aed9b628cf855e6b50e1"
SHA256_LOGO_SHAARIT = "faf0c8a17e7cb82d0884f598eadf2c4fee7a603bfdc3a2ecfdac1e750316db34"
_ENV_BARRE = (
"DRACO_SSO_URL", "DRACO_FLOWDECK_URL", "DRACO_TIKTAK_URL",
"DRACO_SHAARIT_URL", "KANBANTE_PUBLIC_URL", "AGENT_URL",
"AGENT_EVENEMENTS_URL",
)
def _sha256(chemin):
return hashlib.sha256((R / chemin).read_bytes()).hexdigest()
def _nettoyer(monkeypatch):
for nom in _ENV_BARRE:
monkeypatch.delenv(nom, raising=False)
def test_barre_css_js_verbatim_commons_v020():
# Remplacé en 0.45.9 : la re-vendange v0.3.0 (HUB-1) supersède
# les empreintes v0.2.0 — les empreintes courantes sont
# verrouillées par tests/test_barre_v030.py. On garde ici la
# preuve que les fichiers ont bien quitté la v0.2.0.
assert _sha256("static/css/draco-barre.css") != SHA256_CSS_V020
assert _sha256("static/js/draco-barre.js") != SHA256_JS_V020
def test_logo_shaarit_verbatim():
assert _sha256("static/img/logos/shaarit.svg") == SHA256_LOGO_SHAARIT
def test_garde_data_theme_apportee_par_la_v020():
css = (R / "static/css/draco-barre.css").read_text(encoding="utf-8")
# Les deux blocs système sont gardés ; le sombre forcé reste.
assert "html:not([data-theme]) .draco-bar" in css
assert "html:not([data-theme]) .draco-bar .draco-brand-mark img" in css
assert '[data-theme="dark"] .draco-bar' in css
assert "@media (prefers-color-scheme: dark) {\n .draco-bar" not in css
def test_url_shaarit_defaut_officiel(monkeypatch):
from app.config import _env_settings, resoudre_urls_draco
_nettoyer(monkeypatch)
assert _env_settings().shaarit_url == "https://shaarit.dracodev.net"
assert resoudre_urls_draco().shaarit_url == "https://shaarit.dracodev.net"
def test_url_shaarit_derivee_du_seul_agent_evenements(monkeypatch):
from app.config import _env_settings
_nettoyer(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
assert _env_settings().shaarit_url == "https://shaarit-dev.dracodev.net"
def test_url_shaarit_explicite_prioritaire(monkeypatch):
from app.config import _env_settings
_nettoyer(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
monkeypatch.setenv("DRACO_SHAARIT_URL", "https://signets.example.test")
assert _env_settings().shaarit_url == "https://signets.example.test"
def test_url_shaarit_par_hote_de_requete(monkeypatch):
from app.config import resoudre_urls_draco, suffixe_environnement_requete
_nettoyer(monkeypatch)
assert suffixe_environnement_requete("shaarit-dev.dracodev.net") == "-dev"
urls = resoudre_urls_draco("kanbante-dev.dracodev.net")
assert urls.shaarit_url == "https://shaarit-dev.dracodev.net"
urls = resoudre_urls_draco("kanbante.dracodev.net")
assert urls.shaarit_url == "https://shaarit.dracodev.net"
def test_onglet_shaarit_dans_le_rendu(client):
r = client.get("/")
assert r.status_code == 200
assert 'data-app="shaarit"' in r.text
assert "https://shaarit.dracodev.net/" in r.text
assert "/static/img/logos/shaarit.svg" in r.text
# Ordre : Kanbante < ShaarIt < Console Spark (agent).
assert r.text.index('data-app="kanbante"') < r.text.index('data-app="shaarit"')
assert r.text.index('data-app="shaarit"') < r.text.index('data-app="agent"')
assert client.get("/static/img/logos/shaarit.svg").status_code == 200
-93
View File
@@ -1,93 +0,0 @@
"""Barre commune v0.3.0 (HUB-1, fusion #115) — re-vendange 0.45.10.
- CSS/JS vendangés VERBATIM depuis dracodev-commons, branche
hub-menu-compte-v0-3-0 (PR commons #9), ui/barre-apps/.
- Menu compte : liens Hub (Télécharger, À Propos, Aides) et
Profil devenu lien vers <hub>/profil. Point explicite du
chantier : Kanbante traitait l'action relayée « profil » vers
sa page /profile propre — ce comportement est REMPLACÉ par le
lien Hub (un seul Profil, celui du Hub). La branche
onAction("profil") du gabarit est retirée ; la page /profile
elle-même continue d'exister. « Déconnexion » reste relayée.
- URL Hub résolue par le même mécanisme que les autres apps
(explicite DRACO_HUB_URL > hôte de la requête > dérivation par
l'agent > défaut officiel), exposée au gabarit (draco_urls.hub)
et passée en urls.hub de DRACO_BAR_CONFIG.
"""
import hashlib
from pathlib import Path
R = Path(__file__).resolve().parents[1]
# Empreintes SHA-256 de la source commons v0.3.0
# (Projets/dracodev-commons, ui/barre-apps/, branche
# hub-menu-compte-v0-3-0 @ff92ece).
SHA256_CSS_V030 = "4ac2ed2ee04549f40c71e5700149271d5b07b04e4ab88073e94328fcbf9475b0"
SHA256_JS_V030 = "9d84617bfb4346d6a17d769da83d04ebc9bc38d40962c58dd7347f13a6c74da5"
_ENV_BARRE = (
"DRACO_SSO_URL", "DRACO_FLOWDECK_URL", "DRACO_TIKTAK_URL",
"DRACO_SHAARIT_URL", "DRACO_HUB_URL", "KANBANTE_PUBLIC_URL",
"AGENT_URL", "AGENT_EVENEMENTS_URL",
)
def _sha256(chemin):
return hashlib.sha256((R / chemin).read_bytes()).hexdigest()
def _nettoyer(monkeypatch):
for nom in _ENV_BARRE:
monkeypatch.delenv(nom, raising=False)
def test_barre_css_js_verbatim_commons_v030():
# Remplacé en 0.45.11 : la re-vendange v0.4.0 (HUB-11)
# supersède les empreintes v0.3.0 — les empreintes courantes
# sont verrouillées par tests/test_barre_v040.py. On garde ici
# la preuve que les fichiers ont bien quitté la v0.3.0.
assert _sha256("static/css/draco-barre.css") != SHA256_CSS_V030
assert _sha256("static/js/draco-barre.js") != SHA256_JS_V030
def test_js_liens_hub_et_profil_lien():
js = (R / "static/js/draco-barre.js").read_text(encoding="utf-8")
assert 'hub: "https://hub.dracodev.net/"' in js
assert 'hub: "https://hub-dev.dracodev.net/"' in js
assert '"telecharger", "Télécharger"' in js
assert '"a-propos", "À Propos"' in js
assert '"aide", "Aides"' in js
assert '"profil", "Profil"' in js
assert "data-hub-link" in js
assert 'data-compte-act", "profil"' not in js
assert 'data-compte-act", "deconnexion"' in js
def test_gabarit_branche_hub_et_retire_profil_relaye():
gabarit = (R / "app/templates/_draco_bar.html").read_text(encoding="utf-8")
assert 'urls: { hub: "{{ draco_urls.hub }}/" }' in gabarit
# La branche relayée vers /profile a disparu du onAction…
assert 'if (act === "profil")' not in gabarit
# …mais la déconnexion relayée est intacte.
assert 'if (act === "deconnexion") { location.href = "/logout"; return; }' \
in gabarit
def test_hub_resolution_meme_mecanisme(monkeypatch):
_nettoyer(monkeypatch)
from app.config import resoudre_urls_draco
assert resoudre_urls_draco().hub_url == "https://hub.dracodev.net"
assert resoudre_urls_draco("kanbante-dev.dracodev.net").hub_url == \
"https://hub-dev.dracodev.net"
assert resoudre_urls_draco("kanbante.dracodev.net").hub_url == \
"https://hub.dracodev.net"
monkeypatch.setenv("DRACO_HUB_URL", "https://hub.example.net/")
assert resoudre_urls_draco("kanbante-dev.dracodev.net").hub_url == \
"https://hub.example.net"
def test_page_profile_existe_toujours():
# La page propre à Kanbante n'est pas supprimée : seul le
# menu compte de la barre ne la cible plus.
main = (R / "app/main.py").read_text(encoding="utf-8")
assert '@app.get("/profile")' in main
-58
View File
@@ -1,58 +0,0 @@
"""Barre commune v0.4.0 (HUB-11, fusion #125) — re-vendange 0.45.11.
- CSS/JS vendangés VERBATIM depuis dracodev-commons, branche
menu-compte-icones-v0-4-0 (PR commons #10), ui/barre-apps/.
- Menu compte : chaque item (Télécharger, À Propos, Aides,
Profil, Déconnexion) porte une icône SVG monoline construite
en DOM (contour, currentColor, sans remplissage), dans le
style des icônes du rail de la console — aucun emoji.
L'en-tête d'identité reste sans icône ; les liens Hub et le
relais « Déconnexion » sont inchangés depuis la v0.3.0.
"""
import hashlib
from pathlib import Path
R = Path(__file__).resolve().parents[1]
# Empreintes SHA-256 de la source commons v0.4.0
# (Projets/dracodev-commons, ui/barre-apps/, branche
# menu-compte-icones-v0-4-0 @e8ca99a).
SHA256_CSS_V040 = "d8cf53b2f6700ebc7499d43869d5cae256d1f74636f0113bffb174b8a6bd06ec"
SHA256_JS_V040 = "36cc24b2bfea757633a7cbcfded54fb73405d9f6fdbde1b4a8fb8df7ae5d39a0"
def _sha256(chemin):
return hashlib.sha256((R / chemin).read_bytes()).hexdigest()
def test_barre_css_js_verbatim_commons_v040():
# Remplacé en 0.45.12 : la re-vendange v0.4.1 (correctif
# fusion #125) supersède les empreintes v0.4.0 — les empreintes
# courantes sont verrouillées par tests/test_barre_v041.py.
# Le CSS a lui aussi quitté la v0.4.0 : la v0.4.1 le resserre
# (menu compte 248 → 232 px, @edde1b5) ; on garde ici la
# preuve que les deux fichiers ont bien quitté la v0.4.0.
assert _sha256("static/css/draco-barre.css") != SHA256_CSS_V040
assert _sha256("static/js/draco-barre.js") != SHA256_JS_V040
def test_js_icones_monoline_menu_compte():
js = (R / "static/js/draco-barre.js").read_text(encoding="utf-8")
assert "MENU_ICONES" in js
for nom in ("telecharger", "a-propos", "aide", "profil", "deconnexion"):
assert f'"{nom}"' in js
assert "draco-menu-icone" in js
assert 'setAttribute("stroke", "currentColor")' in js
assert 'setAttribute("fill", "none")' in js
# Comportements v0.3.0 conservés.
assert 'hub: "https://hub.dracodev.net/"' in js
assert '"profil", "Profil"' in js
assert 'data-compte-act", "deconnexion"' in js
def test_css_icone_menu_compte():
css = (R / "static/css/draco-barre.css").read_text(encoding="utf-8")
assert ".draco-menu-icone" in css
bloc = css.split(".draco-menu-icone {", 1)[1].split("}", 1)[0]
assert "width: 18px" in bloc
assert "var(--ink-2)" in bloc
-53
View File
@@ -1,53 +0,0 @@
"""Barre commune v0.4.1 (correctif fusion #125) — re-vendange 0.45.12.
- JS vendangé VERBATIM depuis dracodev-commons, branche
menu-compte-icones-svg-ns-v0-4-1 (PR commons #11),
ui/barre-apps/. Le CSS est lui aussi verbatim : la source
commons v0.4.1 y ajoute le resserrement du menu compte
(min-width 248 → 232 px, demande de Bruno, @edde1b5).
- Correctif : en v0.4.0, les icônes du menu compte étaient
créées via document.createElement() — espace de noms HTML,
jamais dessinées par le navigateur (libellés décalés d'un
cran d'icône, aucun glyphe visible en production). La v0.4.1
crée le <svg> et ses primitives via
document.createElementNS() (espace de noms SVG).
"""
import hashlib
from pathlib import Path
R = Path(__file__).resolve().parents[1]
# Empreintes SHA-256 de la source commons
# (Projets/dracodev-commons, ui/barre-apps/ ; branche du
# correctif v0.4.1 @edde1b5 : JS espace de noms SVG, CSS
# menu compte resserré à 232 px).
SHA256_CSS_V041 = "874b26eafba5b3e174140280415edbae00cd38bc4beb39d897a4b4c5b923af6e"
SHA256_JS_V041 = "82c4940e8e115ed71d95f7c0dcdc906b93e49855580cc0fec7a1ccba34f85a42"
def _sha256(chemin):
return hashlib.sha256((R / chemin).read_bytes()).hexdigest()
def test_barre_css_js_verbatim_commons_v041():
# Remplacé en 0.46.1 : la re-vendange v0.5.0 (HUB-18, thème
# commun) supersède l'empreinte JS v0.4.1 — l'empreinte courante
# est verrouillée par tests/test_barre_v050.py. Le CSS, inchangé
# en v0.5.0, garde son empreinte v0.4.1 ; le JS a quitté la v0.4.1.
assert _sha256("static/css/draco-barre.css") == SHA256_CSS_V041
assert _sha256("static/js/draco-barre.js") != SHA256_JS_V041
def test_js_icones_espace_de_noms_svg():
js = (R / "static/js/draco-barre.js").read_text(encoding="utf-8")
assert "MENU_ICONES" in js
# Le correctif : création dans l'espace de noms SVG…
assert 'createElementNS(SVG_NS, "svg")' in js
assert "createElementNS(SVG_NS, spec[0])" in js
# …et plus aucune création HTML du svg ou des primitives.
assert 'createElement("svg")' not in js
assert "createElement(spec[0])" not in js
# Comportements conservés.
assert 'hub: "https://hub.dracodev.net/"' in js
assert '"profil", "Profil"' in js
assert 'data-compte-act", "deconnexion"' in js
-46
View File
@@ -1,46 +0,0 @@
"""Barre commune v0.5.0 (HUB-18, fusion #132) — re-vendange 0.46.1.
- JS vendangé VERBATIM depuis dracodev-commons, branche
hub18-theme-commun (PR commons #12), ui/barre-apps/. Le CSS est
inchangé en v0.5.0 : il garde l'empreinte v0.4.1 (verrouillée
par tests/test_barre_v041.py).
- Thème commun : la barre apprend la préférence du Hub
(GET <hub>/api/profil/theme) et l'applique à l'ouverture ;
l'initialisation propre à Kanbante vit dans static/js/nav.js
(écouteur draco:theme → data-theme + resynchronisation du
bouton de bascule, sans écraser le choix local kanbante-theme).
"""
import hashlib
from pathlib import Path
R = Path(__file__).resolve().parents[1]
SHA256_JS_V050 = "384e2b0248a7c84d77fbb6eae6c25366828554162556a6b5b19c96e188ce11c7"
def _sha256(chemin):
return hashlib.sha256((R / chemin).read_bytes()).hexdigest()
def test_barre_js_verbatim_commons_v050():
assert _sha256("static/js/draco-barre.js") == SHA256_JS_V050
def test_js_theme_commun_hub18():
js = (R / "static/js/draco-barre.js").read_text(encoding="utf-8")
assert "api/profil/theme" in js
assert 'credentials: "include"' in js
assert "draco-theme-hub" in js
assert 'CustomEvent("draco:theme"' in js
assert "themePref" in js and "themeMode" in js
assert "resolvedUrls.hub" in js
# Comportements conservés (v0.4.1) : icônes en espace de noms SVG.
assert 'createElementNS(SVG_NS, "svg")' in js
def test_nav_js_init_theme_hub():
nav = (R / "static/js/nav.js").read_text(encoding="utf-8")
assert 'addEventListener(\'draco:theme\'' in nav or 'addEventListener("draco:theme"' in nav
assert "appliquerThemeCommun" in nav
assert "syncThemeBtn(mode)" in nav
assert "DRACO_BAR" in nav and "themeMode" in nav
-27
View File
@@ -1,27 +0,0 @@
"""Barre commune v0.6.0 (fusion #142) — entrée « Administration ».
Re-vendange verbatim du JS/CSS commons v0.6.0 : le menu compte
gagne l'entrée « Administration » (lien Hub <hub>/admin), à
visibilité conditionnelle. Kanbante ne passe pas le drapeau
admin : il ne connaît que le rôle du registre du Hub pour sa
propre app, pas le rôle global de suite — l'entrée reste donc
masquée ici.
"""
from pathlib import Path
R = Path(__file__).resolve().parents[1]
def test_barre_v060_entree_administration_vendangee():
js = (R / "static/js/draco-barre.js").read_text(encoding="utf-8")
assert 'setAttribute("data-hub-link", "admin")' in js
assert '"Administration"' in js
assert "setAdmin" in js
css = (R / "static/css/draco-barre.css").read_text(encoding="utf-8")
assert ".draco-menu-item[hidden]" in css
def test_gabarit_sans_drapeau_admin():
gabarit = (R / "app/templates/_draco_bar.html").read_text(encoding="utf-8")
assert "v0.6.0" in gabarit
assert "admin: true" not in gabarit
-34
View File
@@ -1,34 +0,0 @@
"""Menu ⋯ des tuiles projets : copie d'URL (#44) et thème
opaque barre Fusion / Spark, sombre et clair (#45).
Le menu était transparent parce que son fond utilisait
var(--surface-1), variable jamais définie dans :root.
"""
def test_card_menu_offers_copy_url(client, fake_gitea):
page = client.get("/")
assert "Copier l'URL du projet" in page.text
assert 'data-copy-url="http://gitea.test/Projets/demo"' in page.text
def test_card_menu_theme_is_opaque_and_dual(client, fake_gitea):
css = client.get("/static/css/style.css").text
# Le menu ne repose plus sur la variable indéfinie.
menu = css.split(".card-menu {", 1)[1].split("}", 1)[0]
assert "var(--surface-1)" not in menu
assert "var(--menu-bg)" in menu
# Jetons barre Fusion / Console v7.2, clair par défaut…
assert "--menu-bg: #ffffff" in css
# …et variante sombre explicite, comme draco-barre.css.
assert '[data-theme="dark"] .card-menu' in css
assert "--menu-bg: #1a1c1a" in css
assert "prefers-color-scheme: dark" in css
def test_dashboard_js_copy_contract(client, fake_gitea):
js = client.get("/static/js/dashboard.js").text
assert "data-copy-url" in js
assert "navigator.clipboard" in js
assert "execCommand('copy')" in js
assert "URL du projet copiée" in js
-12
View File
@@ -1,12 +0,0 @@
"""Page Projets (#47) : les fichiers qui portent le menu ⋯ et la
copie d'URL doivent être versionnés, sinon le navigateur garde
l'ancien CSS/JS après une release alors que le HTML est neuf —
c'est exactement ce qui a fait paraître la 0.38.0 inefficace
(menu toujours transparent, bouton de copie présent mais inerte).
"""
def test_dashboard_static_assets_are_versioned(client, fake_gitea):
page = client.get("/")
assert "/static/css/style.css?v=" in page.text
assert "/static/js/dashboard.js?v=" in page.text
-21
View File
@@ -1,21 +0,0 @@
"""Déconnexion du menu compte (fusion #141) — branchement global.
Kanbante était le seul hôte branché, mais sa branche ne faisait que
naviguer vers son /logout local : la session draco (SSO) survivait
et le pont /auth/draco rouvrait une session sans mot de passe. Le
branchement livré : destruction de la session locale (GET /logout)
puis navigation vers la déconnexion globale de l'émettrice
(GET /sso/logout chez ObsiGate, retour ici).
"""
from pathlib import Path
R = Path(__file__).resolve().parents[1]
def test_gabarit_branche_deconnexion_globale():
gabarit = (R / "app/templates/_draco_bar.html").read_text(encoding="utf-8")
assert 'act === "deconnexion"' in gabarit
assert 'fetch("/logout"' in gabarit
assert "{{ draco_urls.obsigate }}/sso/logout?return_to=" in gabarit
# L'ancien branchement local seul a disparu.
assert 'if (act === "deconnexion") { location.href = "/logout"; return; }' not in gabarit
+1 -1
View File
@@ -198,7 +198,7 @@ def test_menu_des_applications_par_la_marque(client):
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 ".draco-brand-mark img" in css # v0.1.10 : la marque est le logo Spark (26 px), plus de glyphe 24 px
assert "font-size: 24px" in css
def test_barre_pleine_largeur_et_palier_mobile(client):
-396
View File
@@ -1,396 +0,0 @@
"""HUB-16 (0.46.0) — correspondance draco PAR UTILISATEUR.
Le pont SSO ne désigne plus « l'opérateur » : chaque identité
draco (claim ``sub``) désigne SON compte Kanbante, via la colonne
users.draco_sub. Couvert ici :
- amorçage : l'unique compte admin sans liaison se lie au
premier passage (le compte existant garde son accès) ;
- JIT : deux identités distinctes → deux comptes distincts,
rôle membre, mot de passe local non divulgué/inutilisable ;
- le JIT n'adopte jamais un compte préexistant non lié, même
homonyme : il crée un compte nouveau, suffixé ;
- liaison / déliaison par l'administrateur depuis /admin,
conflit de sub refusé ;
- compte désactivé : correspondance refusée, aucun contournement ;
- /api/search : un jeton draco sans correspondance ne suffit plus,
un jeton lié passe ;
- non-régression : un jeton personnel kbt_ reste accepté sur les
surfaces machine pendant que l'authentification est active.
La base de test est partagée par toute la session (voir
conftest) : les comptes créés ici portent des noms/subs uniques
« hub16-… » et sont supprimés au teardown de chaque test.
"""
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 accounts, draco, models
SECRET = "secret-partage-hub16"
SUBS_TEST = (
"hub16-alice", "hub16-bob", "hub16-carole", "hub16-dave",
"hub16-erin", "hub16-franck", "hub16-amorce", "hub16-sans-lien",
"hub16-recherche",
)
def _jeton(sub, nom="Alice Exemple", secret=SECRET):
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": nom, "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
def _nettoyer():
"""Supprime les comptes créés par ces tests (et leurs traces)."""
with models.get_db() as conn:
ids = [
r[0] for r in conn.execute(
"SELECT id FROM users WHERE draco_sub IN ({})".format(
",".join("?" * len(SUBS_TEST))
),
SUBS_TEST,
).fetchall()
]
ids += [
r[0] for r in conn.execute(
"SELECT id FROM users WHERE username IN"
" ('hub16-dave', 'hub16-erin', 'carole-exemple')"
).fetchall()
]
for uid in ids:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute(
"DELETE FROM workspace_members WHERE user_id = ?", (uid,)
)
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def _restaurer_temoin():
"""Délie le compte témoin d'amorçage (jamais supprimé)."""
temoin = models.get_user_by_draco("hub16-temoin-amorce")
if temoin is not None:
models.update_user(temoin["id"], draco_sub=None)
@pytest.fixture()
def client_hub16(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()
_restaurer_temoin()
_nettoyer()
with TestClient(main.app) as client:
yield client
_restaurer_temoin()
_nettoyer()
draco._revoked_cache.clear()
def _pont(client, sub, nom="Alice Exemple"):
client.cookies.set("draco_session", _jeton(sub, nom))
return client.get("/auth/draco", follow_redirects=False)
def _profil_username(client) -> str:
r = client.get("/profile")
assert r.status_code == 200
# « <username> · rôle : … » dans l'en-tête de la page profil.
return r.text.split("page-sub\">", 1)[1].split(" ·", 1)[0].strip()
def _unique_admin_sans_liaison():
"""(compte, vrai) si l'état partagé est le cas d'amorçage pur."""
users = models.list_users()
if len(users) == 1 and users[0]["role"] == "admin" \
and not users[0].get("draco_sub"):
return users[0], True
return None, False
# ── Amorçage ──────────────────────────────────────────────────
def test_amorcage_unique_admin_se_lie_au_premier_passage(client_hub16):
admin, pur = _unique_admin_sans_liaison()
if not pur:
pytest.skip("base partagée : le cas d'amorçage pur n'est pas en place")
avant = models.count_users()
r = _pont(client_hub16, "hub16-amorce", "Bruno")
assert r.status_code == 303 and r.headers["location"] == "/"
assert "kanbante_session" in r.headers.get("set-cookie", "")
# Aucun compte créé : le sub s'est lié au compte admin existant.
assert models.count_users() == avant
lie = models.get_user_by_draco("hub16-amorce")
assert lie is not None and lie["id"] == admin["id"]
assert lie["role"] == "admin"
assert _profil_username(client_hub16) == admin["username"]
# Nettoyage propre à ce test : rendre l'amorçage à son état.
models.update_user(admin["id"], draco_sub=None)
# ── Création JIT ──────────────────────────────────────────────
def test_jit_deux_identites_deux_comptes_distincts(client_hub16):
admin, pur = _unique_admin_sans_liaison()
if pur:
# Neutraliser l'amorçage : les scénarios JIT doivent être purs.
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
try:
r = _pont(client_hub16, "hub16-alice", "Alice Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
assert _profil_username(client_hub16) == "alice-exemple"
alice = models.get_user_by_draco("hub16-alice")
assert alice is not None
assert alice["username"] == "alice-exemple"
assert alice["display_name"] == "Alice Exemple"
assert alice["role"] == "member"
# Mot de passe local aléatoire : ni le mot de passe
# d'environnement ni le nom ne l'ouvrent.
assert accounts.verify_password("motdepasse", alice["password_hash"]) is False
assert alice["password_hash"].startswith("pbkdf2$")
client_hub16.cookies.delete("kanbante_session")
r = _pont(client_hub16, "hub16-bob", "Bob Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
assert _profil_username(client_hub16) == "bob-exemple"
bob = models.get_user_by_draco("hub16-bob")
assert bob is not None and bob["id"] != alice["id"]
# Deuxième passage d'Alice : MÊME compte, pas de doublon.
client_hub16.cookies.delete("kanbante_session")
avant = models.count_users()
_pont(client_hub16, "hub16-alice", "Alice Exemple")
assert models.count_users() == avant
assert models.get_user_by_draco("hub16-alice")["id"] == alice["id"]
assert _profil_username(client_hub16) == "alice-exemple"
finally:
if pur:
models.update_user(admin["id"], draco_sub=None)
def test_jit_n_adopte_pas_un_compte_homonyme_non_lie(client_hub16):
admin, pur = _unique_admin_sans_liaison()
if pur:
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
try:
local_id = models.create_user(
"carole-exemple", accounts.hash_password("local-carole"),
role="member", display_name="Carole Exemple",
)
r = _pont(client_hub16, "hub16-carole", "Carole Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
# Le compte préexistant n'a PAS été lié ni utilisé…
local = models.get_user(local_id)
assert local["draco_sub"] is None
# …un compte nouveau, suffixé, porte la correspondance.
cree = models.get_user_by_draco("hub16-carole")
assert cree is not None and cree["id"] != local_id
assert cree["username"] == "carole-exemple-2"
assert _profil_username(client_hub16) == "carole-exemple-2"
finally:
if pur:
models.update_user(admin["id"], draco_sub=None)
def test_compte_desactive_refuse_sans_contournement(client_hub16):
admin, pur = _unique_admin_sans_liaison()
if pur:
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
try:
_pont(client_hub16, "hub16-franck", "Franck Exemple")
franck = models.get_user_by_draco("hub16-franck")
assert franck is not None
models.update_user(franck["id"], active=0)
client_hub16.cookies.delete("kanbante_session")
avant = models.count_users()
r = _pont(client_hub16, "hub16-franck", "Franck Exemple")
# Refusé au login, aucune session, aucun nouveau compte.
assert r.headers["location"] == "/login"
assert "kanbante_session" not in r.headers.get("set-cookie", "")
assert models.count_users() == avant
finally:
if pur:
models.update_user(admin["id"], draco_sub=None)
# ── Liaison par l'administrateur ──────────────────────────────
def _jeton_csrf_via_login(client) -> str:
"""Charge la page de connexion (elle pose le cookie CSRF).
csrf_from() charge « / », qui répond 401 JSON sans cookie
quand l'authentification est active et qu'aucune session
n'est ouverte — la page /login, elle, est rendue (render()
pose le cookie) dans tous les cas.
"""
client.get("/login")
return client.cookies.get("kanbante_csrf") or ""
def _connecter_admin(client):
token = _jeton_csrf_via_login(client)
r = client.post(
"/login",
data={"username": "bruno", "password": "motdepasse",
"next": "/", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303, r.text
assert "kanbante_session" in r.headers.get("set-cookie", "")
def test_liaison_par_administrateur(client_hub16):
dave_id = models.create_user(
"hub16-dave", accounts.hash_password("local-dave"),
role="member", display_name="Dave",
)
erin_id = models.create_user(
"hub16-erin", accounts.hash_password("local-erin"),
role="member", display_name="Erin",
)
_connecter_admin(client_hub16)
# L'écran d'administration expose le mécanisme de liaison.
r = client_hub16.get("/admin")
assert r.status_code == 200
assert "/draco" in r.text and "Lier" in r.text
# Lier Dave au sub hub16-dave.
r = client_hub16.post(
f"/admin/users/{dave_id}/draco",
data={"draco_sub": "hub16-dave",
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
follow_redirects=False,
)
assert r.status_code == 303 and r.headers["location"].startswith("/admin?msg=")
assert models.get_user(dave_id)["draco_sub"] == "hub16-dave"
# Conflit : le même sub pour Erin est refusé, Erin inchangée.
r = client_hub16.post(
f"/admin/users/{erin_id}/draco",
data={"draco_sub": "hub16-dave",
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
follow_redirects=False,
)
assert r.status_code == 303 and "err=" in r.headers["location"]
assert models.get_user(erin_id)["draco_sub"] is None
# Le pont route désormais ce sub vers Dave, et seulement lui.
client_hub16.cookies.delete("kanbante_session")
r = _pont(client_hub16, "hub16-dave", "Dave")
assert r.status_code == 303 and r.headers["location"] == "/"
assert _profil_username(client_hub16) == "hub16-dave"
# Déliaison par l'administrateur (champ vide).
_connecter_admin(client_hub16)
r = client_hub16.post(
f"/admin/users/{dave_id}/draco",
data={"draco_sub": "",
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
follow_redirects=False,
)
assert r.status_code == 303
assert models.get_user(dave_id)["draco_sub"] is None
def test_liaison_refusee_aux_non_admins(client_hub16):
dave_id = models.create_user(
"hub16-dave", accounts.hash_password("local-dave"),
role="member", display_name="Dave",
)
# Connexion native de Dave (membre) : aucun droit d'administration.
token = _jeton_csrf_via_login(client_hub16)
r = client_hub16.post(
"/login",
data={"username": "hub16-dave", "password": "local-dave",
"next": "/", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
r = client_hub16.post(
f"/admin/users/{dave_id}/draco",
data={"draco_sub": "hub16-dave",
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
follow_redirects=False,
)
assert r.status_code == 403
assert models.get_user(dave_id)["draco_sub"] is None
# ── /api/search par utilisateur ───────────────────────────────
def test_api_search_draco_par_utilisateur(client_hub16):
admin, pur = _unique_admin_sans_liaison()
if pur:
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
try:
# Jeton draco valide mais SANS correspondance : refusé
# (il ne désigne plus l'opérateur).
client_hub16.cookies.set(
"draco_session", _jeton("hub16-sans-lien", "Sans Lien")
)
r = client_hub16.get("/api/search", params={"q": "demo"})
assert r.status_code == 401
# Après création JIT par le pont, le même chemin passe.
_pont(client_hub16, "hub16-recherche", "Recherche Exemple")
client_hub16.cookies.delete("kanbante_session")
client_hub16.cookies.set(
"draco_session", _jeton("hub16-recherche", "Recherche Exemple")
)
r = client_hub16.get("/api/search", params={"q": "demo"})
assert r.status_code == 200
assert r.json()["query"] == "demo"
finally:
if pur:
models.update_user(admin["id"], draco_sub=None)
# ── Non-régression : jetons kbt_ ──────────────────────────────
def test_jeton_kbt_toujours_accepte(client_hub16):
uid = models.create_user(
"hub16-erin", accounts.hash_password("local-erin"),
role="member", display_name="Erin Jetons",
)
user = models.get_user(uid)
_row, clair = models.create_api_token(uid, "service-agents")
assert clair.startswith("kbt_")
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05"}}
# Sans jeton : 401 ; avec le jeton Bearer : le handshake passe.
assert client_hub16.post("/mcp", json=corps).status_code == 401
r = client_hub16.post(
"/mcp", json=corps, headers={"Authorization": f"Bearer {clair}"}
)
assert r.status_code == 200
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
# Le jeton sert aussi /api/search (surface machine)…
r = client_hub16.get(
"/api/search", params={"q": "demo"},
headers={"Authorization": f"Bearer {clair}"},
)
assert r.status_code == 200
# …mais jamais l'interface web.
assert client_hub16.get(
"/", headers={"Authorization": f"Bearer {clair}",
"Accept": "application/json"},
).status_code == 401
assert user["username"] == "hub16-erin"
+3 -6
View File
@@ -2,12 +2,9 @@
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. Depuis la 0.46.0 (HUB-16), la correspondance est
par utilisateur (users.draco_sub, création JIT) — voir
tests/test_draco_correspondance.py. Les cas couverts ici restent
valides : dans ce module, le seul compte est l'admin d'amorçage,
auquel le premier jeton valide se lie (cas d'amorçage documenté).
Sans DRACO_SESSION_SECRET, le pont renvoie simplement au login.
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
-375
View File
@@ -1,375 +0,0 @@
"""HUB-19 (0.46.2) — rôles du Hub propagés à Kanbante (contrat D3(a)).
Portage du pilote ShaarIt 0.7.0. Couvert ici :
- client ``app.hub_roles`` : appel ``GET {hub}/api/roles/kanbante``
(Cookie complet relayé, ``Accept: application/json``), URL du
Hub résolue comme la barre (explicite > hôte de la requête >
défaut officiel), cache 60 s par ``sub``, horodatage posé avant
l'appel (échec = ancien cache conservé, pas de rafale), tout
échec = rôle inconnu (``None``) ;
- pont ``/auth/draco`` : la consultation précède la résolution et
la création JIT — rôle connu ``aucun`` → refus explicite 403,
**aucune session ouverte, aucun compte créé**, aucune liaison à
une session native préexistante ;
- ``/admin`` : session établie par draco + rôle connu ≠
``administrateur`` → refusé malgré le rôle local admin ; rôle
``administrateur`` → accepté ; rôle inconnu → repli local ;
session **native** (même admin, même cookie draco présent) →
règles locales inchangées.
"""
import base64
import hashlib
import hmac
import json
import time
import httpx
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import draco, hub_roles, models
SECRET = "secret-hub19-partage"
SUBS_TEST = (
"hub19-refuse", "hub19-membre", "hub19-chef", "hub19-inconnu",
"hub19-sans-liaison",
)
def _jeton(sub, nom="Alice Exemple", secret=SECRET):
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": nom, "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
def _sub_depuis_cookie(cookie: str) -> str:
for morceau in (cookie or "").split(";"):
nom, _, valeur = morceau.strip().partition("=")
if nom == "draco_session" and valeur.count(".") == 2:
segment = valeur.split(".")[1]
segment += "=" * (-len(segment) % 4)
try:
return json.loads(base64.urlsafe_b64decode(segment))["sub"]
except Exception:
return ""
return ""
class _Reponse:
def __init__(self, status_code=200, donnees=None):
self.status_code = status_code
self._donnees = donnees
def json(self):
if isinstance(self._donnees, Exception):
raise self._donnees
return self._donnees
@pytest.fixture(autouse=True)
def _cache_propre():
hub_roles._reset_cache()
yield
hub_roles._reset_cache()
@pytest.fixture()
def faux_hub(monkeypatch):
"""Faux registre du Hub : rôles par ``sub``, lu dans le cookie relayé."""
etat = {"roles": {}, "appels": [], "mode": "ok"}
def faux_get(url, headers=None, timeout=None):
etat["appels"].append(
{"url": url, "headers": dict(headers or {}), "timeout": timeout}
)
if etat["mode"] == "panne":
raise httpx.ConnectError("Hub injoignable (simulation)")
if etat["mode"] == "erreur500":
return _Reponse(500, {"detail": "erreur"})
sub = _sub_depuis_cookie((headers or {}).get("Cookie", ""))
role = etat["roles"].get(sub, "utilisateur")
return _Reponse(200, {
"app": "kanbante", "sub": sub, "role": role,
"explicite": True, "ttl_secondes": 60,
})
monkeypatch.setattr(httpx, "get", faux_get)
return etat
# ── Client : contrat d'appel et résolution d'URL ─────────────
def test_client_appel_contrat_et_cache(faux_hub):
faux_hub["roles"]["sub-a"] = "administrateur"
cookie = f"draco_session={_jeton('sub-a')}; autre=1"
assert hub_roles.role_pour(cookie, "sub-a") == "administrateur"
# Deuxième appel dans le TTL : servi par le cache, aucun appel.
assert hub_roles.role_pour(cookie, "sub-a") == "administrateur"
assert len(faux_hub["appels"]) == 1
appel = faux_hub["appels"][0]
assert appel["url"] == "https://hub.dracodev.net/api/roles/kanbante"
assert appel["headers"]["Cookie"] == cookie
assert appel["headers"]["Accept"] == "application/json"
assert appel["timeout"] == 2.0
def test_client_url_hub_par_hote_dev(faux_hub):
hub_roles.role_pour(
f"draco_session={_jeton('sub-b')}", "sub-b",
hote="kanbante-dev.dracodev.net",
)
assert faux_hub["appels"][0]["url"] == (
"https://hub-dev.dracodev.net/api/roles/kanbante"
)
def test_client_url_hub_explicite_prioritaire(faux_hub, monkeypatch):
monkeypatch.setenv("DRACO_HUB_URL", "https://hub.exemple.test/")
hub_roles.role_pour(
f"draco_session={_jeton('sub-c')}", "sub-c",
hote="kanbante-dev.dracodev.net",
)
assert faux_hub["appels"][0]["url"] == (
"https://hub.exemple.test/api/roles/kanbante"
)
def test_client_ttl_expire_reconsulte(faux_hub):
cookie = f"draco_session={_jeton('sub-d')}"
faux_hub["roles"]["sub-d"] = "utilisateur"
assert hub_roles.role_pour(cookie, "sub-d") == "utilisateur"
hub_roles._cache_fetched_at -= hub_roles.CACHE_TTL + 1
faux_hub["roles"]["sub-d"] = "administrateur"
assert hub_roles.role_pour(cookie, "sub-d") == "administrateur"
assert len(faux_hub["appels"]) == 2
def test_client_echec_inconnu_et_ancien_cache_conserve(faux_hub):
cookie = f"draco_session={_jeton('sub-e')}"
faux_hub["roles"]["sub-e"] = "utilisateur"
assert hub_roles.role_pour(cookie, "sub-e") == "utilisateur"
# TTL expiré, Hub en panne : l'appel tenté rend « inconnu »…
hub_roles._cache_fetched_at -= hub_roles.CACHE_TTL + 1
faux_hub["mode"] = "panne"
assert hub_roles.role_pour(cookie, "sub-e") is None
appels = len(faux_hub["appels"])
# …mais l'horodatage posé avant l'appel garde l'ancien cache :
# l'appel suivant (dans le nouveau TTL) le ressert sans rafale.
assert hub_roles.role_pour(cookie, "sub-e") == "utilisateur"
assert len(faux_hub["appels"]) == appels
def test_client_reponses_illisibles_inconnues(monkeypatch):
def faux_get(url, headers=None, timeout=None):
return _Reponse(200, {"role": "super-utilisateur"})
monkeypatch.setattr(httpx, "get", faux_get)
assert hub_roles.role_pour("c=1", "sub-f") is None
hub_roles._reset_cache()
def faux_get_403(url, headers=None, timeout=None):
return _Reponse(403, {"role": "aucun"})
monkeypatch.setattr(httpx, "get", faux_get_403)
assert hub_roles.role_pour("c=1", "sub-f") is None
# ── Intégration : pont draco et garde /admin ─────────────────
def _nettoyer():
with models.get_db() as conn:
ids = [
r[0] for r in conn.execute(
"SELECT id FROM users WHERE draco_sub IN ({})".format(
",".join("?" * len(SUBS_TEST))
),
SUBS_TEST,
).fetchall()
]
for uid in ids:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute(
"DELETE FROM workspace_members WHERE user_id = ?", (uid,)
)
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
@pytest.fixture()
def client_hub19(monkeypatch, fake_gitea, faux_hub):
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()
_nettoyer()
with TestClient(main.app) as client:
admin = next(u for u in models.list_users() if u["role"] == "admin")
sub_avant = admin.get("draco_sub")
try:
yield client, faux_hub, admin
finally:
models.update_user(admin["id"], draco_sub=sub_avant)
_nettoyer()
draco._revoked_cache.clear()
def _neutraliser_amorcage(admin) -> bool:
"""Vrai si l'amorçage pur a été neutralisé (témoin lié à l'admin).
Même patron que tests/test_draco_correspondance.py : les
scénarios JIT exigent que l'unique admin sans liaison ne
capture pas le premier sub de passage.
"""
users = models.list_users()
if len(users) == 1 and users[0]["role"] == "admin" \
and not users[0].get("draco_sub"):
models.update_user(users[0]["id"], draco_sub="hub19-temoin-amorce")
return True
return False
def _pont(client, sub, nom="Alice Exemple"):
client.cookies.set("draco_session", _jeton(sub, nom))
return client.get("/auth/draco", follow_redirects=False)
def test_pont_role_aucun_refuse_sans_creation(client_hub19):
client, hub, _admin = client_hub19
hub["roles"]["hub19-refuse"] = "aucun"
avant = models.count_users()
r = _pont(client, "hub19-refuse", "Refusée Exemple")
assert r.status_code == 403
assert "Accès refusé" in r.text
assert "« aucun »" in r.text
# Aucune session Kanbante ouverte par draco…
assert "kanbante_session" not in r.headers.get("set-cookie", "")
# …et aucun compte JIT créé pour ce sub.
assert models.count_users() == avant
assert models.get_user_by_draco("hub19-refuse") is None
def test_pont_role_aucun_ne_lie_pas_une_session_native(client_hub19):
client, hub, admin = client_hub19
hub["roles"]["hub19-sans-liaison"] = "aucun"
# Session native préexistante (format historique, sans marqueur).
client.cookies.set(
"kanbante_session", main._make_session(admin["username"])
)
r = _pont(client, "hub19-sans-liaison")
assert r.status_code == 403
# La consultation a précédé la liaison : rien n'a été lié.
assert models.get_user_by_draco("hub19-sans-liaison") is None
# La session native elle-même n'est pas touchée.
assert client.get("/").status_code == 200
def test_pont_role_utilisateur_ouvre_session_jit(client_hub19):
client, hub, admin = client_hub19
hub["roles"]["hub19-membre"] = "utilisateur"
pur = _neutraliser_amorcage(admin)
try:
r = _pont(client, "hub19-membre", "Membre Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
assert "kanbante_session" in r.headers.get("set-cookie", "")
cree = models.get_user_by_draco("hub19-membre")
assert cree is not None and cree["role"] == "member"
assert client.get("/").status_code == 200
finally:
if pur:
models.update_user(admin["id"], draco_sub=None)
def test_pont_role_inconnu_comportement_actuel(client_hub19):
client, hub, admin = client_hub19
hub["mode"] = "panne"
pur = _neutraliser_amorcage(admin)
try:
r = _pont(client, "hub19-inconnu", "Inconnu Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
assert models.get_user_by_draco("hub19-inconnu") is not None
finally:
if pur:
models.update_user(admin["id"], draco_sub=None)
def _admin_lie_au_pont(client, hub, admin, role_hub):
"""Lie le compte admin local au sub chef, puis ouvre sa session par le pont."""
models.update_user(admin["id"], draco_sub="hub19-chef")
hub["roles"]["hub19-chef"] = role_hub
hub_roles._reset_cache()
r = _pont(client, "hub19-chef", "Chef Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
return r
def test_admin_session_draco_exige_administrateur_du_registre(client_hub19):
client, hub, admin = client_hub19
_admin_lie_au_pont(client, hub, admin, "utilisateur")
# Rôle local admin, mais registre connu « utilisateur » : refusé.
r = client.get("/admin")
assert r.status_code == 403
# Registre passé à « administrateur » : accepté.
hub["roles"]["hub19-chef"] = "administrateur"
hub_roles._reset_cache()
r = client.get("/admin")
assert r.status_code == 200
def test_admin_session_draco_role_inconnu_repli_local(client_hub19):
client, hub, admin = client_hub19
_admin_lie_au_pont(client, hub, admin, "administrateur")
assert client.get("/admin").status_code == 200
# Hub en panne et cache expiré : rôle inconnu → règles locales.
hub["mode"] = "panne"
hub_roles._cache_fetched_at -= hub_roles.CACHE_TTL + 1
# L'appel de garde rend « inconnu » (l'ancien cache ne ressert
# qu'aux appels suivants, patron du pilote) : le repli local joue.
r = client.get("/admin")
assert r.status_code == 200
def test_admin_session_native_non_soumise_au_registre(client_hub19):
client, hub, admin = client_hub19
models.update_user(admin["id"], draco_sub="hub19-chef")
hub["roles"]["hub19-chef"] = "utilisateur"
# Session NATIVE de l'admin (mot de passe / format historique) :
# même avec un cookie draco présent et un rôle registre connu
# « utilisateur », /admin reste régi par les règles locales.
client.cookies.set(
"kanbante_session", main._make_session(admin["username"])
)
client.cookies.set("draco_session", _jeton("hub19-chef", "Chef Exemple"))
r = client.get("/admin")
assert r.status_code == 200
def test_marqueur_session_draco_signe():
"""Le marqueur « établie par draco » est couvert par la signature."""
from starlette.requests import Request
def requete(valeur):
scope = {
"type": "http", "method": "GET", "path": "/",
"headers": [(b"cookie", f"kanbante_session={valeur}".encode())],
}
return Request(scope)
natif = main._make_session("bruno")
assert main._session_info(requete(natif)) == ("bruno", False)
via = main._make_session("bruno", via_draco=True)
assert main._session_info(requete(via)) == ("bruno", True)
# Marqueur ajouté à la main sur un jeton natif : signature fausse.
pirate = natif.rsplit(".", 1)[0] + ".draco." + natif.rsplit(".", 1)[1]
assert main._session_info(requete(pirate)) is None
-36
View File
@@ -1,36 +0,0 @@
"""Icônes monochromes (#59, suite) : plus d'emoji colorés dans les
gabarits applicatifs (hors barre Fusion partagée, favicons et
contenu utilisateur ), étoile et thème en symboles monochromes.
"""
def test_pas_d_emoji_colores_dans_les_gabarits():
import pathlib
emoji = ["🎯", "🔮", "🩺", "📖", "🏷️", "🧱", "➕", "📝", "💾", "📎", "💬",
"📄", "📲", "📤", "✍️", "⚡", "🔌", "📱", "📈", "💤", "👥", "🔭",
"📜", "🧾", "🧠", "👀", "📦", "🐌", "🧟", "🗝️", "🐙", "🍵", "🟢",
"🔴", "✅", "❌", "⭐", "🐢", "⏱️", "📊", "🗣️", "🛩️", "⚙️", "📰",
"🗄️", "🖥️", "🛠️", "🧭", "🎛️", "⌨️", "🔗", "🌆", "🚧", "🚫", "⏭️",
"🔁", "☀️", "🌙"]
for path in pathlib.Path("app/templates").glob("*.html"):
if path.name in ("_draco_bar.html",):
continue
text = path.read_text(encoding="utf-8")
for line in text.splitlines():
if 'rel="icon"' in line:
continue
for e in emoji:
assert e not in line, (path.name, e, line.strip()[:100])
def test_etoile_et_theme_monochromes(client, fake_gitea):
css = client.get("/static/css/style.css").text
# L'étoile n'est plus jaune : couleur texte, épinglée = texte plein.
# (#fbbf24 reste pour l'âge des cartes sur le wallboard, une
# information sémantique, pas une icône.)
assert '.star-btn[data-starred="1"]' in css
assert "color: var(--text-2);" in css
js = client.get("/static/js/stars.js").text
assert "⭐" not in js
nav = client.get("/static/js/nav.js").text
assert "☀️" not in nav and "🌙" not in nav
-37
View File
@@ -1,37 +0,0 @@
"""Identité Kanbante (#54, #55) : sidebar charbon (pas bleutée),
accent vert Kanbante (pas rose Spark), icônes de navigation en
trait (pas d'emoji), bouton Dark/Light dans la section
Administration (pas dans l'en-tête).
"""
def test_sidebar_charbon_et_accent_vert(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert "rgba(26, 28, 26, 0.96)" in css
assert "rgba(13, 16, 24" not in css
assert "--accent: #0d9488;" in css
assert "#c2437e" not in css
def test_nav_icons_sont_des_svg(client, fake_gitea):
page = client.get("/")
assert 'class="side-ico"><svg' in page.text
# Le logo de la barre latérale est le logo officiel
# (kanbante.png, celui de la barre d'apps commune) —
# remplacement demandé par Bruno le 2026-10-30.
assert 'class="logo-mark"><img src="/static/img/logos/kanbante.png"' in page.text
def test_theme_toggle_dans_admin_pas_dans_en_tete(client, fake_gitea):
page = client.get("/")
assert 'id="theme-toggle"' in page.text
head = page.text.split("header-actions", 1)[1].split("</header>", 1)[0]
assert 'id="theme-toggle"' not in head
# Placement administrateur : le bouton est dans le bloc de la
# section Administration du gabarit (les non-admins, eux, le
# voient dans le pied de la barre, la section n'existant pas
# pour eux — sans quoi ils perdraient le basculeur).
tpl = open("app/templates/_header.html", encoding="utf-8").read()
admin_block = tpl.split("gkey == 'admin'", 1)[1]
assert 'id="theme-toggle"' in admin_block
assert "data-theme-label" in admin_block
-47
View File
@@ -1,47 +0,0 @@
"""Retours de Bruno (2026-10-30) : logo de la barre latérale,
board en thème clair, badge d'âge illisible en clair.
1. La barre latérale montrait un pictogramme « 3 barres » ;
le logo doit être celui de la barre d'apps commune
(static/img/logos/kanbante.png).
2. En thème clair, les colonnes du board et le panneau Notes
gardaient le fond sombre codé en dur rgba(20,24,34,.72).
3. Le badge d'âge (En vol, Focus) : texte jaune pâle sur
fond jaune translucide — illisible en thème clair.
"""
def test_sidebar_logo_officiel(client, fake_gitea):
page = client.get("/")
assert 'src="/static/img/logos/kanbante.png"' in page.text
# L'ancien pictogramme à barres blanches a disparu.
assert '<rect x="3" y="4" width="5" height="16"' not in page.text
def test_logo_officiel_servi(client, fake_gitea):
r = client.get("/static/img/logos/kanbante.png")
assert r.status_code == 200
assert r.content[:4] == b"\x89PNG"
def test_board_clair_surfaces_eclaircies(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert (
'html[data-theme="light"] .kanban-column '
'{ background: rgba(255, 255, 255, 0.85); }'
) in css
assert (
'html[data-theme="light"] .notes-panel '
'{ background: rgba(255, 255, 255, 0.85); }'
) in css
def test_badge_age_lisible_en_clair(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert 'html[data-theme="light"] .age-badge { color: #b45309; }' in css
assert (
'html[data-theme="light"] .age-badge.stale { color: #dc2626; }'
) in css
assert (
'html[data-theme="light"] .column-count.over { color: #dc2626; }'
) in css
-35
View File
@@ -1,35 +0,0 @@
"""Mobile : tiroir, pied et menu compte.
Bugs signalés sur captures (29 octobre 2026) :
- tiroir rétréci à 68 px en mobile quand l'état « replié » desktop
était mémorisé (spécificité de `body.sidebar-collapsed .sidebar`) ;
- pied de sidebar masqué en mobile (`display: none`) ;
- Profil / Déconnexion du menu compte draco sans effet (onAction
ne les prenait pas en charge).
"""
def test_tiroir_mobile_largeur_fixe():
css = open("static/css/style.css", encoding="utf-8").read()
assert "body.sidebar-collapsed .sidebar,\n body.sidebar-open.sidebar-collapsed .sidebar { width: 264px; }" in css
assert "body.sidebar-open { overflow: hidden; }" in css
def test_pied_visible_en_mobile():
css = open("static/css/style.css", encoding="utf-8").read()
assert ".side-foot { display: none; }" not in css
assert "body.sidebar-collapsed .side-foot-meta { display: flex; }" in css
def test_menu_compte_draco_fonctionnel():
tpl = open("app/templates/_draco_bar.html", encoding="utf-8").read()
assert 'act === "profil"' in tpl
assert 'location.href = "/profile"' in tpl
assert 'act === "deconnexion"' in tpl
assert 'location.href = "/logout"' in tpl
def test_tiroir_ferme_en_mobile():
js = open("static/js/nav.js", encoding="utf-8").read()
assert "window.innerWidth <= 900" in js
assert "querySelectorAll('.side-link')" in js
-39
View File
@@ -1,39 +0,0 @@
"""Navigation (#51) et bascule Dark/Light (#52).
L'accordéon de la barre latérale omettait la section admin :
le JS posait nav-closed-admin, mais aucun style ne la suivait.
Le thème clair, lui, doit être atteignable par un bouton et
mémorisé, avec les jetons draco clairs.
"""
def test_admin_accordion_closes(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert 'body.nav-closed-admin [data-group="admin"] .side-acc' in css
assert 'body.nav-closed-admin [data-group="admin"] .side-chevron' in css
def test_theme_toggle_present_and_versioned(client, fake_gitea):
page = client.get("/")
assert 'id="theme-toggle"' in page.text
assert "/static/js/nav.js?v=" in page.text
# #55 : le bouton n'est plus dans l'en-tête applicatif.
head = page.text.split("header-actions", 1)[1].split("</header>", 1)[0]
assert 'id="theme-toggle"' not in head
def test_theme_js_contract(client, fake_gitea):
js = client.get("/static/js/nav.js").text
assert "kanbante-theme" in js
assert "theme-toggle" in js
assert "data-theme" in js
def test_light_theme_tokens(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert 'html[data-theme="light"]' in css
assert "--bg: #f2f2f0;" in css
assert "--surface: #ffffff;" in css
assert "--accent-2: #0f766e;" in css
assert 'html[data-theme="light"] .draco-bar' in css
assert 'html[data-theme="light"] .card-menu' in css
+2 -2
View File
@@ -25,7 +25,7 @@ def test_sidebar_on_app_pages(client, fake_gitea):
# Bouton palette : icône seule, à droite du titre — pas de
# libellé texte « Recherche & commandes » visible.
assert 'class="side-head"' in page.text
assert 'class="side-search palette-trigger"' in page.text
assert ">🔍</button>" in page.text
# Le libellé de l'ancien bouton palette a disparu (le lien de
# navigation « Recherche », lui, existe toujours — c'est le
# texte complet « Recherche & commandes » qui doit manquer).
@@ -89,4 +89,4 @@ def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
page = client.get("/wall")
assert page.status_code == 200
assert "+3 autres cartes en vol" in page.text
assert "Ouvertes" in page.text
assert "📋 Ouvertes" in page.text
+6 -6
View File
@@ -8,7 +8,7 @@ def test_dashboard_shows_next_milestone(client, fake_gitea):
page = client.get("/")
assert page.status_code == 200
# Faux : Sprint 1, échéance 2026-10-20, 1 fermée / 2 au total.
assert "Sprint 1" in page.text
assert "🎯 Sprint 1" in page.text
assert "1/2 terminées" in page.text
assert "échéance" in page.text
@@ -75,12 +75,12 @@ def test_project_page(client, fake_gitea):
)
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "Jalons ouverts" in page.text
assert "🎯 Jalons ouverts" in page.text
assert "Sprint 1" in page.text
assert "Santé du flux" in page.text
assert "Prévisions" in page.text
assert "Activité récente" in page.text
assert "Notes du projet" in page.text
assert "🩺 Santé du flux" in page.text
assert "🔮 Prévisions" in page.text
assert "🕘 Activité récente" in page.text
assert "📝 Notes du projet" in page.text
assert 'href="/board/Projets/demo"' in page.text
assert "/wall?repo=Projets/demo" in page.text
# Titre du projet et lien Gitea du faux.
+1 -1
View File
@@ -19,7 +19,7 @@ def test_activity_line_recent_week(client, fake_gitea):
from_column="backlog", to_column="todo",
created_at=_ts(0))
page = client.get("/")
assert "Cette semaine : 2 fermée(s) · 1 déplacement(s)" in page.text
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
def test_activity_line_idle_project(client, fake_gitea):
+1 -1
View File
@@ -197,7 +197,7 @@ def test_board_filter_by_milestone_id(client):
def test_dashboard_uses_new_design_system(client):
r = client.get("/")
assert "app-header" in r.text
assert "seg-tabs" in r.text
assert "nav-pill" in r.text
# En-tête refondu en 0.20.0 : bande KPI unifiée (les stat-tile
# ne servent plus qu'au briefing).
assert "kpi-strip" in r.text
+1 -1
View File
@@ -8,7 +8,7 @@ from app import models
def test_dashboard_sort_offers_review_mode(client):
r = client.get("/")
assert r.status_code == 200
assert '<option value="review">En attente de revue ↓</option>' in r.text
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
def test_dashboard_cards_carry_review_count(client):
+1 -1
View File
@@ -81,7 +81,7 @@ def test_forecast_page_and_milestone_banner(client, fake_gitea):
assert "Sprint 1" in r.text
board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"})
assert board.status_code == 200
assert "milestone-forecast" in board.text
assert "🔮" in board.text
assert "historique pour prévoir" in board.text # empty journal
assert "/sprint/Projets/demo" in board.text
+19 -30
View File
@@ -18,7 +18,6 @@ RESOLVED_DEV = {
"flowdeck": "https://flowdeck-dev.dracodev.net",
"tiktak": "https://tiktak-dev.dracodev.net/",
"kanbante": "https://kanbante-dev.dracodev.net/",
"shaarit": "https://shaarit-dev.dracodev.net/",
}
@@ -35,38 +34,22 @@ def test_origin_of():
def test_sister_origins_dev():
# Correctif 2026-10-28 : origines officielles prod ET dev, quelle
# que soit la résolution ; ShaarIt ajoutée par la fédération
# v0.2.1 (12 origines : 10 sœurs + 2 agent).
# Correctif 2026-10-28 : 8 origines officielles, prod ET dev,
# quelle que soit la résolution.
assert sister_origins(RESOLVED_DEV) == {
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://shaarit.dracodev.net",
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://shaarit-dev.dracodev.net",
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
}
def test_cors_headers_agent_console_autorise():
# Fédération depuis la console (2026-10-28) : les origines agent
# prod ET dev passent, avec credentials ; un tiers reste refusé.
for origine in ("https://agent.dracodev.net",
"https://agent-dev.dracodev.net"):
headers = cors_headers_for(origine, RESOLVED_DEV)
assert headers is not None
assert headers["Access-Control-Allow-Origin"] == origine
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://agent.dracodev.net.evil.example",
RESOLVED_DEV) is None
def test_cors_headers():
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
@@ -128,14 +111,20 @@ def test_sister_origins_hote_requete():
)
def test_gabarit_recherche_assets_versionnes():
"""Fusion #111 (correctif) : les assets de la page /recherche sont
versionnés (?v=), comme nav.js et la barre — un recherche.js
d'avant la source ShaarIt servi par un cache navigateur laissait
la section ShaarIt figée sur « Tapez une recherche ci-dessus »,
sans aucun appel (retour d'usage du 2026-11-04)."""
from pathlib import Path
gabarit = Path("app/templates/recherche.html").read_text(encoding="utf-8")
assert "/static/js/recherche.js?v={{ version }}" in gabarit
assert "/static/css/recherche.css?v={{ version }}" in gabarit
def test_origines_agent_console():
# Fédération console (défaut 2) : la page de recherche de la
# console appelle /api/search depuis agent(. -dev) avec le
# cookie SSO — ces deux origines passent, prod ET dev, quelle
# que soit la résolution ; un tiers reste refusé.
assert cors_headers_for(
"https://agent.dracodev.net", RESOLVED_DEV) is not None
assert cors_headers_for(
"https://agent-dev.dracodev.net", {}) is not None
assert cors_headers_for(
"https://agent.dracodev.net",
RESOLVED_DEV)["Access-Control-Allow-Credentials"] == "true"
assert preflight_headers(
"https://agent-dev.dracodev.net", RESOLVED_DEV) is not None
assert "https://agent.dracodev.net" in sister_origins({})
assert "https://agent-dev.dracodev.net" in sister_origins({})
assert "https://agent-dev.dracodev.net" in sister_origins()
-31
View File
@@ -1,31 +0,0 @@
"""Thème du site (#49, #54) : Kanbante doit ressembler à la console
agent / barre Fusion (draco-barre, Console v7.2), pas au bleu/mauve
historique — et l'accent est désormais le vert Kanbante, pas le
rose Spark. Le test fige les jetons et interdit le retour des
littéraux rose/violet/bleu de thème dans style.css.
"""
def test_theme_agent_tokens(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert "--bg: #101211;" in css
assert "--surface: #1a1c1a;" in css
assert "--surface-2: #242624;" in css
assert "--surface-3: #20221f;" in css
assert "--border: #2e302c;" in css
assert "--text: #eceee9;" in css
assert "--accent: #0d9488;" in css
assert "--accent-2: #14b8a6;" in css
def test_no_blue_purple_theme_literals(client, fake_gitea):
css = client.get("/static/css/style.css").text
for banned in (
"#7c5cfc", "#5b8af7", "#58a6ff", "#a78bfa", "#7dd3fc",
"#c4b5fd", "#a5c4fd", "#0b0e14", "#141822", "#1b202e",
"#232939", "124, 92, 252", "124,92,252",
"91, 138, 247", "91,138,247",
"#c2437e", "#e078a8", "194, 67, 126", "194,67,126",
"224, 120, 168", "224,120,168",
):
assert banned not in css, banned
-37
View File
@@ -1,37 +0,0 @@
"""Tuile Projets et pied de sidebar : ergonomie refondue.
Tuile : en-tête groupé, avancement labellisé, jalon étiqueté,
pied en 2 rangées avec répartition labellisée par colonne.
Pied : Sortir icône seule, fraîcheur en pastille discrète.
"""
def test_tuile_structure(client, fake_gitea):
page = client.get("/")
assert 'class="card-header-left"' in page.text
assert 'class="card-header-actions"' in page.text
assert 'class="foot-main"' in page.text
assert 'class="foot-dist"' in page.text
# Répartition, avancement et jalon sont conditionnels (stats /
# jalon) : on les vérifie dans le gabarit, pas dans la page de
# test sans données.
tpl = open("app/templates/dashboard.html", encoding="utf-8").read()
assert 'aria-label="Répartition par colonne"' in tpl
assert 'class="progress-head"' in tpl
assert "Avancement" in tpl
assert 'class="ms-kicker">Prochain jalon' in tpl
assert "COLUMN_LABELS[col]" in tpl
def test_pied_sidebar_structure():
tpl = open("app/templates/_header.html", encoding="utf-8").read()
assert 'class="side-foot-user"' in tpl
assert 'class="side-foot-meta"' in tpl
assert 'class="side-logout logout-icon"' in tpl
assert 'aria-label="Se déconnecter"' in tpl
assert " Sortir</span>" not in tpl
assert 'class="fresh-dot"' in tpl
css = open("static/css/style.css", encoding="utf-8").read()
assert ".side-foot-meta" in css
assert ".fresh-dot" in css
assert ".logout-icon" in css
-50
View File
@@ -1,50 +0,0 @@
"""Lot UX (#57 à #68) : jetons, boutons, accent-text, icônes,
thème clair sur connexion/wallboard, en-tête de page commun.
"""
def test_tokens_et_accent_text(client, fake_gitea):
css = client.get("/static/css/style.css").text
for token in (
"--accent-text: #14b8a6;",
"--accent-text: #0f766e;",
"--radius-full: 999px;",
"--shadow-lg:",
"--font-xl: 1.55rem;",
):
assert token in css
assert "linear-gradient(135deg, rgba(13,148,136,.9)" not in css
assert '"Inter"' not in css
def test_boutons_et_skeleton(client, fake_gitea):
css = client.get("/static/css/style.css").text
for cls in (".btn-primary", ".btn-danger", ".btn-icon", ".skeleton", ".field-error-msg"):
assert cls in css
def test_cartes_et_labels_lisibles(client, fake_gitea):
css = client.get("/static/css/style.css").text
assert "-webkit-line-clamp: 2" in css
card = open("app/templates/_card.html", encoding="utf-8").read()
assert "pin-btn btn-icon" in card
assert "color-mix(in srgb" in card
assert "📌" not in card
def test_pas_de_recherche_en_double_et_theme_clair_login_wall(client, fake_gitea):
header = open("app/templates/_header.html", encoding="utf-8").read()
assert "topbar-search" not in header
for tpl in ("app/templates/login.html", "app/templates/wall.html"):
assert "kanbante-theme" in open(tpl, encoding="utf-8").read()
css = client.get("/static/css/style.css").text
assert 'html[data-theme="light"] .wall-body' in css
assert 'html[data-theme="light"] .login-body' in css
def test_pages_avec_en_tete_commun(client, fake_gitea):
today = open("app/templates/today.html", encoding="utf-8").read()
focus = open("app/templates/focus.html", encoding="utf-8").read()
assert 'class="page-head"' in today
assert 'class="page-head"' in focus
assert "🌅" not in today