Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ba9bca3a3 | ||
|
|
4897009a93 | ||
|
|
bb0cc2c72b | ||
|
|
bd8af99652 | ||
|
|
00920e8e02 | ||
|
|
e024bd1a97 | ||
|
|
f88df99c97 | ||
|
|
da522904c7 | ||
|
|
a3aa1f73d1 | ||
|
|
48d0066dbe | ||
|
|
0a5d83737b | ||
|
|
7c457db59f | ||
|
|
169aa78479 | ||
|
|
123029d2e9 | ||
|
|
5402624e20 | ||
|
|
9bc5399b6c | ||
|
|
f6476c786d | ||
|
|
5f19488c47 | ||
|
|
37aa83ee45 | ||
|
|
261a2f952e | ||
|
|
4c64cbc168 | ||
|
|
6856634a69 | ||
|
|
6ccd4adaa5 | ||
|
|
bd84628ba3 | ||
|
|
0cade11843 | ||
|
|
c035c39833 | ||
|
|
e9332d5043 | ||
|
|
08a16cf5fa | ||
|
|
9027cd1e6f | ||
|
|
b039657b84 | ||
|
|
9bd3472b3c | ||
|
|
610e51fb2c | ||
|
|
c098c80ec8 | ||
|
|
248c9468e1 | ||
|
|
d97ca399f0 | ||
|
|
2ffbc2ec58 | ||
|
|
86b12c1386 | ||
|
|
3b0f097d8d | ||
|
|
ac0f15f685 | ||
|
|
f2c684620e | ||
|
|
59b9c284e5 | ||
|
|
1cd66fdc01 | ||
|
|
61d88fd7fc | ||
|
|
1d98f1464c | ||
|
|
d9887c3d70 | ||
|
|
1f11cd8e9d | ||
|
|
d2d9c10f6c | ||
|
|
2fd974c7e4 | ||
|
|
e556cc7bc9 | ||
|
|
77f48d8c93 | ||
|
|
8449c8ba0c | ||
|
|
6d9dc4b8ca | ||
|
|
2921d61b9d | ||
|
|
9ef1049464 | ||
|
|
9667bf1436 | ||
|
|
ba772163cc | ||
|
|
2b85fdffee | ||
|
|
5b5d32d97a | ||
|
|
b0ef76cb76 | ||
|
|
003b95af3e | ||
|
|
ec345572e5 | ||
|
|
4ef515a906 | ||
|
|
5e9d814e6f | ||
|
|
28cfb82ce4 | ||
|
|
347710cc8d | ||
|
|
5e45673597 | ||
|
|
88740a78a0 | ||
|
|
69c347f198 | ||
|
|
0acb02c333 | ||
|
|
9b0ad1b39c | ||
|
|
8ade518402 | ||
|
|
13e66742a1 | ||
|
|
2836c3f71d | ||
|
|
84d94f9e24 | ||
|
|
92176f614b | ||
|
|
93c6a1fc88 | ||
|
|
67c2684ad6 | ||
|
|
895ee81d51 | ||
|
|
d0ba39bf01 | ||
|
|
fc1b40cd98 | ||
|
|
5f184f033c | ||
|
|
d17f3e56b3 | ||
|
|
11b1b5f11d | ||
|
|
37fd4de63d | ||
|
|
1b218ce030 | ||
|
|
40f034e86e | ||
|
|
911f07362f | ||
|
|
ee837972be | ||
|
|
4470e6ccbc | ||
|
|
d8cb4664e5 | ||
|
|
f2e702a3d9 | ||
|
|
435954c31b | ||
|
|
7f66775039 |
@@ -12,12 +12,14 @@ 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)
|
||||
|
||||
@@ -1,3 +1,136 @@
|
||||
## 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.
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 .
|
||||
|
||||
@@ -9,7 +9,10 @@ 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
|
||||
|
||||
@@ -66,3 +69,130 @@ 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
|
||||
|
||||
@@ -130,6 +130,10 @@ 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
|
||||
@@ -139,7 +143,7 @@ Environment variables:
|
||||
ci-dessus (prioritaire, contrat
|
||||
inchangé) ; 2) l'hôte public de la
|
||||
requête, motif fermé
|
||||
``(og|flowdeck|tiktak|kanbante|agent)
|
||||
``(og|flowdeck|tiktak|kanbante|shaarit|agent)
|
||||
<SUFFIXE>.dracodev.net`` (suffixe vide
|
||||
en prod, ``-dev`` en dev) appliqué à
|
||||
toutes les sœurs et à l'agent — jamais
|
||||
@@ -152,7 +156,7 @@ Environment variables:
|
||||
l'URL sœur devient
|
||||
``https://<slug><SUFFIXE>.
|
||||
dracodev.net`` (slugs : og, flowdeck,
|
||||
tiktak, kanbante, agent) ; hôte hors
|
||||
tiktak, kanbante, shaarit, agent) ; hôte hors
|
||||
motif (autre domaine, LAN, IP) =
|
||||
aucune dérivation, jamais de lien
|
||||
deviné ; 4) le défaut officiel (prod).
|
||||
@@ -177,7 +181,8 @@ from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.43.0"
|
||||
_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"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -232,7 +237,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|agent)([a-z0-9-]*)\.dracodev\.net"
|
||||
r"(?:og|flowdeck|tiktak|kanbante|shaarit|agent|hub)([a-z0-9-]*)\.dracodev\.net"
|
||||
)
|
||||
|
||||
|
||||
@@ -265,7 +270,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|agent)<SUFFIXE>
|
||||
Motif fermé ``^(og|flowdeck|tiktak|kanbante|shaarit|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
|
||||
@@ -306,6 +311,8 @@ 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)
|
||||
|
||||
@@ -344,6 +351,10 @@ 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
|
||||
),
|
||||
@@ -393,6 +404,8 @@ 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"
|
||||
@@ -497,6 +510,18 @@ 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,
|
||||
|
||||
@@ -9,9 +9,12 @@ est présenté). Vérification hmac/hashlib — mêmes sémantiques que
|
||||
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
|
||||
|
||||
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
|
||||
en session Kanbante native. Kanbante est mono-utilisateur
|
||||
(identifiants configurés par l'environnement) : un jeton valide
|
||||
désigne l'opérateur configuré — pas de table de liens. Sans secret
|
||||
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
|
||||
configuré, module inerte.
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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
|
||||
@@ -263,23 +263,48 @@ def _session_secret() -> bytes:
|
||||
|
||||
|
||||
|
||||
def _make_session(user: str) -> str:
|
||||
"""Signed session cookie (0.22.0): user.expiry.hmac."""
|
||||
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.
|
||||
"""
|
||||
exp = int(time.time()) + SESSION_DAYS * 86400
|
||||
charge = f"{user}.{exp}.draco" if via_draco else f"{user}.{exp}"
|
||||
sig = hmac.new(
|
||||
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
|
||||
_session_secret(), charge.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{user}.{exp}.{sig}"
|
||||
return f"{charge}.{sig}"
|
||||
|
||||
|
||||
def _session_user(request: Request) -> str | None:
|
||||
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).
|
||||
"""
|
||||
raw = request.cookies.get(SESSION_COOKIE, "")
|
||||
user, _, rest = raw.partition(".")
|
||||
exp, _, sig = rest.partition(".")
|
||||
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}"
|
||||
if not user or not exp or not sig:
|
||||
return None
|
||||
expected = hmac.new(
|
||||
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
|
||||
_session_secret(), charge.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not secrets.compare_digest(sig, expected):
|
||||
return None
|
||||
@@ -288,6 +313,14 @@ def _session_user(request: Request) -> str | 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.
|
||||
@@ -299,6 +332,12 @@ 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", "")
|
||||
@@ -391,15 +430,24 @@ 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 (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.
|
||||
# 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.
|
||||
if path == "/api/search":
|
||||
try:
|
||||
from app import accounts as accounts_service
|
||||
from app import draco
|
||||
|
||||
if draco.claims_for(request) is not None:
|
||||
claims = draco.claims_for(request)
|
||||
if claims is not None and (
|
||||
accounts_service.resolve_draco_user(claims) is not None
|
||||
):
|
||||
return await call_next(request)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -571,67 +619,87 @@ 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 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.
|
||||
(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é.
|
||||
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 = claims.get("sub")
|
||||
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."
|
||||
),
|
||||
)
|
||||
# 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 sub and not current.get("draco_sub"):
|
||||
if 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", str(sub))
|
||||
models.log_admin(current["id"], "draco.link", sub)
|
||||
return RedirectResponse(_safe_next(next), status_code=303)
|
||||
# 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"]:
|
||||
# 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:
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_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),
|
||||
_make_session(user["username"], via_draco=True),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
@@ -1014,6 +1082,28 @@ 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
|
||||
|
||||
|
||||
@@ -1164,6 +1254,62 @@ 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)
|
||||
@@ -2250,6 +2396,8 @@ 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)
|
||||
|
||||
@@ -1012,12 +1012,14 @@ 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) VALUES (?, ?, ?, ?, ?)",
|
||||
(username, password_hash, role, display_name, must_change_password),
|
||||
"must_change_password, draco_sub) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(username, password_hash, role, display_name,
|
||||
must_change_password, draco_sub),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ PROD_ORIGINS = frozenset({
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://shaarit.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
@@ -45,6 +46,7 @@ DEV_ORIGINS = frozenset({
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://shaarit-dev.dracodev.net",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
@@ -54,7 +56,7 @@ AGENT_ORIGINS = frozenset({
|
||||
|
||||
|
||||
# Clés du dictionnaire rendu par render() (app/main.py).
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
@@ -103,6 +105,7 @@ 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
|
||||
|
||||
@@ -1,16 +1,51 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||
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.
|
||||
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).
|
||||
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). #}
|
||||
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. #}
|
||||
<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 }}">
|
||||
@@ -19,29 +54,33 @@
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
<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>
|
||||
</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">▤</span>
|
||||
<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-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">▦</span>
|
||||
<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-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">✓</span>
|
||||
<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-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">▥</span>
|
||||
<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-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:#c2437e">✦</span>
|
||||
<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-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -62,16 +101,12 @@
|
||||
</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).
|
||||
@@ -83,6 +118,12 @@
|
||||
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. */
|
||||
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
|
||||
@@ -103,6 +144,32 @@
|
||||
};
|
||||
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). */
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<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"><svg viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><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="logo-mark"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="34" height="34"></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>
|
||||
@@ -137,19 +137,23 @@
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
{% 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"><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 %}
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% 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" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||
{% endif %}
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
|
||||
<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>
|
||||
<table class="proj-table">
|
||||
<thead>
|
||||
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
|
||||
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco (sub)</th><th>Dernière connexion</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
@@ -38,7 +39,22 @@
|
||||
</form>
|
||||
</td>
|
||||
<td>{% if u.active %}Actif{% else %}Désactivé{% endif %}</td>
|
||||
<td>{% if u.draco_sub %}lié{% else %}—{% 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>{{ u.last_login_at or '—' }}</td>
|
||||
<td class="admin-actions">
|
||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
|
||||
|
||||
@@ -91,14 +91,17 @@
|
||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<div class="card-header">
|
||||
<span class="org-tag">{{ repo.owner.login }}</span>
|
||||
{% if multi_conn %}<span class="conn-badge" title="Connexion {{ repo._conn_slug }}">{% if repo._conn_kind == 'github' %}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 %}
|
||||
<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-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>
|
||||
<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>
|
||||
@@ -115,26 +118,30 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<h3>
|
||||
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||
</h3>
|
||||
<p class="desc">{{ repo.description or '—' }}</p>
|
||||
<p class="desc" title="{{ repo.description or '' }}">{{ 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="progress" title="{{ done_n }}/{{ total }} terminées">
|
||||
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
@@ -143,28 +150,32 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-footer">
|
||||
<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: color-mix(in srgb, {{ COLUMN_COLORS[col] }} 25%, transparent); color: var(--text)">
|
||||
{{ n }}
|
||||
</span>
|
||||
<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 %}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if repo.activity %}
|
||||
<div class="activity-line">
|
||||
{% if repo.activity.closed or repo.activity.moved %}
|
||||
{{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
|
||||
Cette semaine : {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s)
|
||||
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
|
||||
aucune activité kanban depuis {{ repo.activity.idle_days }} j
|
||||
{% endif %}
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<button type="submit" class="btn-mini">Délier</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<link rel="stylesheet" href="/static/css/recherche.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
@@ -16,9 +16,10 @@
|
||||
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 — 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, 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>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
@@ -29,6 +30,7 @@
|
||||
<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">
|
||||
@@ -47,6 +49,10 @@
|
||||
<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>
|
||||
@@ -56,6 +62,6 @@
|
||||
<footer>
|
||||
Kanbante v{{ version }} · recherche partout
|
||||
</footer>
|
||||
<script src="/static/js/recherche.js" defer></script>
|
||||
<script src="/static/js/recherche.js?v={{ version }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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.
|
||||
@@ -7,14 +7,39 @@
|
||||
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. */
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien
|
||||
(aucun attribut data-theme sur <html>). */
|
||||
|
||||
.draco-bar {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
--agent: #c00000; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
@@ -22,8 +47,12 @@
|
||||
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) {
|
||||
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||
html:not([data-theme]) .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;
|
||||
@@ -37,7 +66,15 @@
|
||||
.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 { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
.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; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
@@ -52,7 +89,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: 248px; padding: 6px;
|
||||
min-width: 232px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
@@ -64,6 +101,15 @@
|
||||
text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||
/* 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;
|
||||
@@ -94,6 +140,13 @@
|
||||
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); }
|
||||
@@ -131,18 +184,23 @@
|
||||
|
||||
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
|
||||
.draco-alertes {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
|
||||
position: relative; display: flex; align-items: center; height: 38px;
|
||||
padding: 0 9px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Spark */
|
||||
.draco-spark {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
|
||||
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
|
||||
/* 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);
|
||||
}
|
||||
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||
.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-icon-btn {
|
||||
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||
@@ -169,7 +227,7 @@
|
||||
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
|
||||
.draco-search-txt, .draco-search kbd { 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; }
|
||||
@@ -193,6 +251,9 @@
|
||||
.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. */
|
||||
|
||||
@@ -136,7 +136,9 @@ button, input, select, textarea { font: inherit; }
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
/* Le logo officiel (kanbante.png, le même que la barre
|
||||
d'apps commune) porte son propre fond : conteneur neutre. */
|
||||
background: transparent;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -315,8 +317,15 @@ a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-header-left, .card-header-actions {
|
||||
display: flex; align-items: center; gap: 8px; min-width: 0;
|
||||
}
|
||||
.card-header-actions { flex-shrink: 0; }
|
||||
.card-header .date { white-space: nowrap; }
|
||||
.card-header .star-btn { margin-left: 0; }
|
||||
.org-tag {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
@@ -340,20 +349,33 @@ a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
min-height: 2.4em;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
gap: 10px;
|
||||
display: flex; flex-direction: column; align-items: stretch;
|
||||
font-size: 0.8rem; color: var(--text-2); gap: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px; margin-top: 14px;
|
||||
}
|
||||
.mini-progress { display: flex; gap: 4px; }
|
||||
.foot-main, .foot-dist {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.foot-count { font-weight: 600; color: var(--text); }
|
||||
.card-board-link.btn-sm { text-decoration: none; }
|
||||
.mini-progress { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.mini-progress .chip { white-space: nowrap; }
|
||||
.forecast-line { white-space: nowrap; }
|
||||
.chip {
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.card-progress { margin-bottom: 12px; }
|
||||
.progress-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
gap: 10px; margin-bottom: 6px;
|
||||
font-size: 0.78rem; color: var(--text-2);
|
||||
}
|
||||
.progress-head span:last-child { font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.progress {
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
@@ -1374,7 +1396,7 @@ body.nav-closed-admin [data-group="admin"] .side-chevron { transform: rotate(-90
|
||||
}
|
||||
.side-ico { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.side-ico svg { width: 20px; height: 20px; display: block; }
|
||||
.logo-mark svg { width: 20px; height: 20px; display: block; }
|
||||
.logo-mark img { width: 34px; height: 34px; display: block; border-radius: 10px; }
|
||||
.side-search svg { width: 19px; height: 19px; display: block; }
|
||||
/* Bouton thème dans la section Administration (#55) : même
|
||||
gabarit qu'un lien de navigation, mais c'est un <button>. */
|
||||
@@ -1387,7 +1409,24 @@ body.nav-closed-admin [data-group="admin"] .side-chevron { transform: rotate(-90
|
||||
.theme-toggle-foot:hover { color: var(--text); }
|
||||
.side-foot {
|
||||
margin-top: auto; padding-top: 12px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.side-foot-user { display: flex; align-items: center; gap: 8px; }
|
||||
.side-foot-user .side-profile { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.side-logout.logout-icon {
|
||||
width: 36px; height: 36px; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
}
|
||||
.side-logout.logout-icon:hover { border-color: var(--accent, #0d9488); }
|
||||
.side-foot-meta {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
}
|
||||
.fresh-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--accent-2); display: inline-block; margin-right: 6px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.side-collapse {
|
||||
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
|
||||
@@ -1403,7 +1442,7 @@ body.nav-closed-admin [data-group="admin"] .side-chevron { transform: rotate(-90
|
||||
color: var(--text); border-color: var(--accent, #0d9488);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
|
||||
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; display: inline-flex; align-items: center; }
|
||||
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
|
||||
.side-logout:hover { color: var(--text); text-decoration: none; }
|
||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||
@@ -1412,7 +1451,9 @@ body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||
body.sidebar-collapsed .side-label { display: none; }
|
||||
body.sidebar-collapsed .side-link { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
body.sidebar-collapsed .side-logo { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
body.sidebar-collapsed .side-foot { align-items: center; }
|
||||
body.sidebar-collapsed .side-foot-meta { display: none; }
|
||||
body.sidebar-collapsed .side-foot-user { justify-content: center; }
|
||||
.side-backdrop {
|
||||
position: fixed; inset: 0; z-index: 190;
|
||||
background: rgba(4, 6, 10, 0.55);
|
||||
@@ -1423,12 +1464,30 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
body.with-sidebar, body.with-sidebar.sidebar-collapsed { padding-left: 0; }
|
||||
.sidebar { width: 264px; transform: translateX(-105%); box-shadow: none; }
|
||||
body.sidebar-open .sidebar { transform: none; box-shadow: 18px 0 44px rgba(0,0,0,.5); }
|
||||
/* Tiroir mobile : l'état « replié » mémorisé sur desktop ne doit
|
||||
JAMAIS rétrécir le tiroir. Avant, `body.sidebar-collapsed
|
||||
.sidebar { width: 68px; }` (spécificité supérieure à `.sidebar`)
|
||||
gagnait en mobile : libellés tronqués par overflow-x hidden,
|
||||
exactement la capture image 1. Le tiroir fait 264 px, replié
|
||||
ou non, et le fond ne défile plus derrière lui. */
|
||||
body.sidebar-collapsed .sidebar,
|
||||
body.sidebar-open.sidebar-collapsed .sidebar { width: 264px; }
|
||||
body.sidebar-open { overflow: hidden; }
|
||||
body.sidebar-collapsed .side-label { display: initial; }
|
||||
body.sidebar-collapsed .side-link { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
|
||||
body.sidebar-collapsed .side-logo { justify-content: flex-start; padding-left: 8px; padding-right: 8px; }
|
||||
body.sidebar-collapsed .side-head { flex-direction: row; padding: 4px 6px 12px; }
|
||||
body.sidebar-collapsed .side-search { margin-left: auto; }
|
||||
.side-foot { display: none; }
|
||||
/* Pied visible dans le tiroir (avant : display:none en mobile —
|
||||
aucun moyen de voir la version, la fraîcheur, le thème ni de
|
||||
sortir depuis un téléphone). */
|
||||
.side-foot { display: flex; }
|
||||
body.sidebar-collapsed .side-foot { align-items: stretch; }
|
||||
body.sidebar-collapsed .side-foot-meta { display: flex; }
|
||||
body.sidebar-collapsed .side-foot-user { justify-content: flex-start; }
|
||||
.topbar-search { display: inline-flex; }
|
||||
/* Menus draco (vendangés) : jamais plus larges que l'écran. */
|
||||
header.draco-bar .draco-compte-menu, header.draco-bar .draco-apps-menu { max-width: calc(100vw - 20px); }
|
||||
}
|
||||
|
||||
/* ── Dashboard vague 2 (0.17.0, #17 #18 #20) + page Projet (#25) ── */
|
||||
@@ -1449,7 +1508,19 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.star-btn:hover { transform: scale(1.2); }
|
||||
.star-btn:disabled { opacity: 0.5; }
|
||||
.card-board-link { font-weight: 600; }
|
||||
.ms-block { margin: 10px 0 2px; }
|
||||
.ms-block {
|
||||
margin: 14px 0 2px; padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.ms-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
gap: 10px; margin-bottom: 6px;
|
||||
}
|
||||
.ms-kicker {
|
||||
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.6px;
|
||||
color: var(--text-3); font-weight: 700;
|
||||
}
|
||||
.ms-block .ms-line { margin-bottom: 2px; }
|
||||
.ms-line { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
|
||||
.ms-title { font-weight: 600; font-size: 0.92rem; }
|
||||
.ms-due { font-size: 0.8rem; color: var(--text-2); white-space: nowrap; }
|
||||
@@ -1485,7 +1556,7 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
|
||||
|
||||
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
|
||||
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
|
||||
.activity-line { margin-top: 10px; font-size: 0.78rem; color: var(--text-3); }
|
||||
/* Barre d'outils unique du dashboard (0.20.0) : onglets
|
||||
segmentés, filtre, tri, regroupement et vue dans UN cadre. */
|
||||
.dash-toolbar {
|
||||
@@ -1890,6 +1961,21 @@ html[data-theme="light"] .draco-bar {
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
}
|
||||
|
||||
/* Alertes unifiées (Fusion #69, retour de Bruno 2026-10-30) :
|
||||
le CSS neutre draco-alertes.css 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 alors que --text suit le thème : en mode clair,
|
||||
texte sombre sur fond sombre, panneau illisible. On branche
|
||||
les variables sur les jetons Kanbante, qui suivent déjà
|
||||
html[data-theme] — le panneau devient correct dans les
|
||||
deux thèmes. (--text et --border existent déjà.) */
|
||||
.draco-alertes-box {
|
||||
--bg-primary: var(--surface);
|
||||
--text-dim: var(--text-2);
|
||||
--text-tertiary: var(--text-3);
|
||||
}
|
||||
|
||||
/* Squelette de chargement (#65) : tuiles et colonnes, sans JS. */
|
||||
.skeleton { background: var(--surface-2); border-radius: var(--radius); animation: skeleton-pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes skeleton-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
@@ -1911,3 +1997,17 @@ html[data-theme="light"] .draco-bar {
|
||||
sombres par défaut — ils suivent html[data-theme]. */
|
||||
html[data-theme="light"] .wall-body { background: #f2f2f0; color: #1b1d1a; }
|
||||
html[data-theme="light"] .login-body { background: #f2f2f0; }
|
||||
|
||||
/* Thème clair (retour de Bruno, 2026-10-30) : le board gardait
|
||||
ses colonnes et son panneau Notes sur le fond sombre codé en
|
||||
dur (rgba(20,24,34,.72)) — surfaces claires, comme la barre
|
||||
latérale et l'en-tête. */
|
||||
html[data-theme="light"] .kanban-column { background: rgba(255, 255, 255, 0.85); }
|
||||
html[data-theme="light"] .notes-panel { background: rgba(255, 255, 255, 0.85); }
|
||||
/* Badge d'âge des cartes (En vol, Focus) : le jaune pâle
|
||||
#fcd34d sur fond jaune translucide était illisible en clair —
|
||||
ambre et rouge foncés, contraste vérifié. Même famille :
|
||||
le compteur de colonne au-delà de sa limite WIP. */
|
||||
html[data-theme="light"] .age-badge { color: #b45309; }
|
||||
html[data-theme="light"] .age-badge.stale { color: #dc2626; }
|
||||
html[data-theme="light"] .column-count.over { color: #dc2626; }
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,8 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 760 B |
@@ -8,7 +8,40 @@
|
||||
* 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 »).
|
||||
* « 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.
|
||||
*
|
||||
* 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
|
||||
@@ -26,8 +59,10 @@
|
||||
* 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/"
|
||||
* spark: "https://spark.dracodev.net/",
|
||||
* hub: "https://hub.dracodev.net/"
|
||||
* },
|
||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||
* } // qu'elle ne connaît pas
|
||||
@@ -54,10 +89,19 @@
|
||||
* 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 : ObsiGate, Flowdeck,
|
||||
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
|
||||
* 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
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls.
|
||||
* 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).
|
||||
* 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…).
|
||||
@@ -132,6 +176,14 @@
|
||||
* 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";
|
||||
@@ -144,8 +196,10 @@
|
||||
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/"
|
||||
spark: "https://spark.dracodev.net/",
|
||||
hub: "https://hub.dracodev.net/"
|
||||
};
|
||||
|
||||
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
|
||||
@@ -157,12 +211,14 @@
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
shaarit: "https://shaarit-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/",
|
||||
hub: "https://hub-dev.dracodev.net/"
|
||||
};
|
||||
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante"
|
||||
tiktak: "TikTak", kanbante: "Kanbante", shaarit: "ShaarIt"
|
||||
};
|
||||
|
||||
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
|
||||
@@ -218,6 +274,131 @@
|
||||
}
|
||||
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. */
|
||||
@@ -296,6 +477,28 @@
|
||||
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;
|
||||
@@ -365,11 +568,15 @@
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
|
||||
/* Entrée « Console Spark » (#70) : après les onglets d'apps
|
||||
* (ObsiGate…ShaarIt, v0.2.0), 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 ; l'icône ✦ reprend la couleur agent du CSS. */
|
||||
* 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. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
@@ -379,10 +586,18 @@
|
||||
sparkItem.href = resolvedUrls.agent || "#";
|
||||
sparkItem.dataset.app = "agent";
|
||||
sparkItem.setAttribute("role", "menuitem");
|
||||
var sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#c2437e");
|
||||
sparkIco.textContent = "✦";
|
||||
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 = "✦";
|
||||
}
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
@@ -406,10 +621,80 @@
|
||||
if (!appsMenu.hidden) lastOpener = brand;
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
/* 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" }]
|
||||
],
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
function buildCompteMenu() {
|
||||
if (!bar || !avatarBtn || compteMenu) return;
|
||||
compteMenu = document.createElement("div");
|
||||
@@ -420,15 +705,28 @@
|
||||
var head = document.createElement("div");
|
||||
head.className = "draco-compte-nom";
|
||||
compteMenu.appendChild(head);
|
||||
[["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);
|
||||
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);
|
||||
});
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
compteMenu.appendChild(sep);
|
||||
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() {
|
||||
@@ -436,6 +734,7 @@
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
refreshCompteHrefs();
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
buildCompteMenu();
|
||||
@@ -527,11 +826,15 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -540,6 +843,7 @@
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
initThemeCommun();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -63,8 +63,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* 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');
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* 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) : la configuration vient
|
||||
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
||||
* 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).
|
||||
*
|
||||
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
|
||||
* ShaarIt, Console Spark. « Tout » = les 5 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
|
||||
@@ -46,6 +48,11 @@
|
||||
* -> { 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,
|
||||
@@ -87,8 +94,9 @@
|
||||
* 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 4 API de recherche
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
|
||||
* (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)
|
||||
* 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 "*") ;
|
||||
@@ -109,12 +117,14 @@
|
||||
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/";
|
||||
@@ -125,6 +135,7 @@
|
||||
{ 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 = {
|
||||
@@ -145,7 +156,7 @@
|
||||
planification: "/?section=planifie",
|
||||
identite: "/?section=identites",
|
||||
};
|
||||
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "shaarit", "console"];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
@@ -227,7 +238,7 @@
|
||||
return TABS.indexOf(v) !== -1 ? v : "all";
|
||||
}
|
||||
|
||||
/* Sections visibles pour un onglet. « Tout » = les 4 sections
|
||||
/* Sections visibles pour un onglet. « Tout » = les 5 sections
|
||||
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
|
||||
function visibleKeys(tab) {
|
||||
tab = parseTab(tab);
|
||||
@@ -256,7 +267,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 4 sections sont en erreur/inactives. */
|
||||
* connus, "" si les 5 sections sont en erreur/inactives. */
|
||||
function tabCounts(states) {
|
||||
states = states || {};
|
||||
var out = {};
|
||||
@@ -367,6 +378,22 @@
|
||||
};
|
||||
});
|
||||
}
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -455,13 +482,16 @@
|
||||
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") {
|
||||
if (app === "tiktak" || app === "kanbante" || app === "shaarit") {
|
||||
return "Ouvrir la recherche dans " + appLabel(app);
|
||||
}
|
||||
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
||||
|
||||
@@ -61,8 +61,18 @@ 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)");
|
||||
|
||||
|
||||
@@ -70,15 +80,17 @@ 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 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");
|
||||
/* 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");
|
||||
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"]), "onglet inconnu -> Tout");
|
||||
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "onglet inconnu -> Tout");
|
||||
|
||||
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||
@@ -91,9 +103,11 @@ 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");
|
||||
@@ -102,18 +116,20 @@ 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 === "7", "Tout = somme des totaux connus");
|
||||
ok(c.all === "8", "Tout = somme des totaux connus (5 sections)");
|
||||
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 4 sections sont en erreur");
|
||||
ok(c.all === "", "Tout vide si les 5 sections sont en erreur");
|
||||
|
||||
/* buildSearchUrl : état onglet partageable */
|
||||
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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
|
||||
@@ -0,0 +1,21 @@
|
||||
"""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
|
||||
@@ -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 "font-size: 24px" in css
|
||||
assert ".draco-brand-mark img" in css # v0.1.10 : la marque est le logo Spark (26 px), plus de glyphe 24 px
|
||||
|
||||
|
||||
def test_barre_pleine_largeur_et_palier_mobile(client):
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""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"
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
|
||||
(secret partagé, HS256) et converti en session Kanbante native par
|
||||
GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide
|
||||
ouvre la session de l'opérateur configuré. Sans
|
||||
DRACO_SESSION_SECRET, le pont renvoie simplement au login.
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""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
|
||||
@@ -16,8 +16,10 @@ def test_sidebar_charbon_et_accent_vert(client, fake_gitea):
|
||||
def test_nav_icons_sont_des_svg(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'class="side-ico"><svg' in page.text
|
||||
# Le logo Kanbante est aussi un SVG (colonnes blanches).
|
||||
assert 'class="logo-mark"><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):
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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
|
||||
@@ -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 "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
|
||||
assert "Cette semaine : 2 fermée(s) · 1 déplacement(s)" in page.text
|
||||
|
||||
|
||||
def test_activity_line_idle_project(client, fake_gitea):
|
||||
|
||||
@@ -18,6 +18,7 @@ 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/",
|
||||
}
|
||||
|
||||
|
||||
@@ -34,17 +35,20 @@ def test_origin_of():
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
|
||||
# quelle que soit la résolution.
|
||||
# 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).
|
||||
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",
|
||||
}
|
||||
@@ -122,3 +126,16 @@ def test_sister_origins_hote_requete():
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante.dracodev.net"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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
|
||||