Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463dd7b752 | ||
|
|
e6f5cff870 | ||
|
|
bd5357f589 | ||
|
|
5203b6288e | ||
|
|
50d7f337da | ||
|
|
0eec63a5ca | ||
|
|
296d9cfa25 | ||
|
|
be3e4e5f69 | ||
|
|
54bd43e9f2 | ||
|
|
9068e1572f | ||
|
|
23bcea4b56 | ||
|
|
e7c65b6f49 | ||
|
|
77b4ee29e6 | ||
|
|
95ba369e04 | ||
|
|
a75a477fea | ||
|
|
c8beccaa2e | ||
|
|
08e912d56f | ||
|
|
0464f6156e | ||
|
|
8bba621223 | ||
|
|
da5000b727 | ||
|
|
3539611aa7 | ||
|
|
1f82c2653a | ||
|
|
e24b2a9495 | ||
|
|
73cd12d9de | ||
|
|
d113ce8c5f | ||
|
|
4b1888eebb | ||
|
|
1e733f3662 | ||
|
|
6eccacd982 | ||
|
|
5508d21e0a | ||
|
|
49bc6ca54a | ||
|
|
b58172dcdd | ||
|
|
4fdfc0c385 | ||
|
|
e2886ccc95 | ||
|
|
427df2ca92 | ||
|
|
1a717ede16 | ||
|
|
d3abeb197a | ||
|
|
dee5fb5752 | ||
|
|
9de1166661 | ||
|
|
c0add7b6de | ||
|
|
d29986e51d | ||
|
|
cd8969a3a2 | ||
|
|
e8cbd8e0f3 | ||
|
|
d40044e0c4 | ||
|
|
41865c702a | ||
|
|
56b8ff2072 | ||
|
|
fc5431308c | ||
|
|
7d31af8ec2 | ||
|
|
2e73802f5f | ||
|
|
46b876eea8 | ||
|
|
2431bb6059 | ||
|
|
ac2f0c8cfc | ||
|
|
58ad5d9164 | ||
|
|
91fad4eba3 | ||
|
|
7995dbe9d2 | ||
|
|
0e3e6ec197 | ||
|
|
d6ea617ed7 | ||
|
|
f7c2ea0d34 | ||
|
|
d16666be85 | ||
|
|
fb4ce38403 | ||
|
|
34253c7b3f | ||
|
|
4490a2ed70 | ||
|
|
c9f7754f5f | ||
|
|
43e416a889 | ||
|
|
fefd9b1ad3 | ||
|
|
3b43be3761 | ||
|
|
972d40a75f | ||
|
|
fb942bc871 | ||
|
|
b8ebea1479 | ||
|
|
ee065978b2 | ||
|
|
71c7113a8c | ||
|
|
c0bf0e8523 | ||
|
|
9ae054d1ba | ||
|
|
1e552a55a4 | ||
|
|
fd9df60788 | ||
|
|
984e92b593 | ||
|
|
8ba38a69a4 | ||
|
|
ee477bf2d1 | ||
|
|
ba5f8c8eab | ||
|
|
d4fef9c1bf | ||
|
|
f708fa77e2 | ||
|
|
7711bd3af7 | ||
|
|
b4923493ac | ||
|
|
0ead9fea4a | ||
|
|
8b871ee39f | ||
|
|
171fdf063b | ||
|
|
ea12938e7c | ||
|
|
3f14dc7130 | ||
|
|
71454f94d1 | ||
|
|
3145cea8e1 | ||
|
|
f49eb62172 | ||
|
|
746ab4c0e1 | ||
|
|
cdf943aff5 | ||
|
|
751daa43dd | ||
|
|
3895e79167 | ||
|
|
8dcddfe34f | ||
|
|
b506a447c5 | ||
|
|
9ca831f816 | ||
|
|
c09a0ac51d | ||
|
|
2d3a93511b | ||
|
|
dd979f81a7 | ||
|
|
c2df8d8c00 | ||
|
|
a930c8192e | ||
|
|
021d0a93f3 | ||
|
|
40571049d6 | ||
|
|
6eb7b78534 | ||
|
|
31a84539f2 | ||
|
|
7250ebc78f | ||
|
|
167e93e295 | ||
|
|
30edf2f0c0 | ||
|
|
67718d1d3c | ||
|
|
1dd148532b | ||
|
|
bd9ecf69e1 | ||
|
|
4b1df2d2b9 | ||
|
|
fe389d46bc | ||
|
|
eef46891de | ||
|
|
7f9eb080b1 | ||
|
|
9abd6c9d4d | ||
|
|
fbe9151ab7 | ||
|
|
ab63000f5d | ||
|
|
52b62b48b8 | ||
|
|
acaae92e3a | ||
|
|
1065ffb017 | ||
|
|
36c97e1553 | ||
|
|
c356a56648 | ||
|
|
c2a222fc3b | ||
|
|
e4be8578ff | ||
|
|
e733f3309d | ||
|
|
371908ed1d | ||
|
|
da205feec1 |
@@ -38,6 +38,7 @@ OBSIGATE_CONSUMER_TOKEN=
|
||||
DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net
|
||||
TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net
|
||||
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
|
||||
SHAARIT_URL= # ShaarIt (barre v0.2.0) — défaut https://shaarit.dracodev.net ; dev : https://shaarit-dev.dracodev.net
|
||||
FLOWDECK_URL= # URL publique de CETTE instance — défaut : dérivée d'OAUTH_REDIRECT_URI, sinon https://flowdeck.dracodev.net ; dev : https://flowdeck-dev.dracodev.net
|
||||
# SSO draco (Fusion ACC-3.2) — secret partagé avec l'émettrice ObsiGate.
|
||||
DRACO_SESSION_SECRET=
|
||||
|
||||
@@ -1,5 +1,128 @@
|
||||
## v7.85.3 (2026-11-06) — HUB-21 : consommation des réglages généraux du Hub
|
||||
|
||||
- **Client des réglages généraux** (`app/services/hub_reglages.py`, nouveau) : propagation du pilote TikTak 0.61.22. Consultation côté serveur de `GET {hub}/api/reglages/effectifs` — endpoint public, aucun cookie, `Accept: application/json`, délai 2,0 s. URL du Hub résolue comme le reste de la suite (`draco_urls.hub_url()` : hôte de la requête, puis dérivation par l'hôte de l'agent — `xxx-dev` → `hub-dev` — jamais d'URL dev figée ni de variable explicite qui figerait la dérivation). Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Forme validée strictement (liste fermée D6(a)) ; toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels (`recherche_partout: true`, `banniere: ""`, `domaine_defaut: null`), jamais d'exception.
|
||||
- **Bannière d'annonce** : global Jinja `banniere_hub` (fonction, jamais un instantané) consommé par `app/templates/base.html` — bandeau sous la barre commune, texte rendu par l'autoescape Jinja (jamais de HTML injecté), bouton « fermer » qui mémorise le texte fermé en `sessionStorage` (clé `flowdeck_banniere_masquee`) : une nouvelle annonce réapparaît. Bannière vide = aucun fragment rendu, zéro changement visuel. Styles `.banniere-hub*` dans `static/css/app.css` (fond `var(--accent)`). Hors mode embarqué (`embed_mode`), jamais sur la page publique.
|
||||
- **Drapeau `recherche_partout`** : la route `/recherche` pose `data-recherche-partout` sur la coquille ; le JS vendangé `static/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console quand la valeur est `false` — leurs sections affichent « Recherche fédérée désactivée… », la recherche locale Flowdeck continue ; attribut absent ou illisible = fédération active (repli).
|
||||
- **`domaine_defaut` non consommé** : Flowdeck n'a pas de notion de domaine (Pro/Famille/Perso) propre — le sélecteur de la barre commune pilote le panneau Spark, pas les vues Flowdeck. La valeur est lue et validée par le client, sans effet local ; seul l'hôte console (agent) l'applique.
|
||||
- Tests : `tests/test_hub_reglages.py` (nouveau, 13 tests — validation de la forme, contrat exact de l'appel, cache 60 s, ancien cache après échec, repli Hub muet, bannière présente et échappée dans la page /recherche, absente si vide ou Hub muet, attribut du drapeau rendu selon la valeur) ; `tests/recherche.test.js` étendu (fonction pure `federationAutorisee`, 92 assertions).
|
||||
|
||||
## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a))
|
||||
|
||||
- **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement.
|
||||
- **Refus `aucun` avant la session** : le pont `GET /auth/draco` (seul point où un cookie draco aboutit à une session locale) appelle `user_for_draco(..., refuser_aucun=True)` ; un rôle connu `aucun` lève `draco.RoleAucunRefuse` **avant** la résolution de compte et donc avant le provisionnement JIT HUB-15 — aucun compte ni lien n'est créé pour un `sub` refusé. Réponse 403 en français nommant le registre des rôles du Hub. Exemptions (patron du pilote) : session locale préexistante (accès de secours par mot de passe, le pont ne refuse pas), page de connexion, santé et statiques (jamais passées par le pont), contrats API propres (OAuth externe Gitea/GitHub, jetons Bearer/SCIM de Flowdeck — non draco, intacts) et recherche fédérée (`/api/search`, repli draco sans création de session : défaut `refuser_aucun=False`, aucun refus nouveau).
|
||||
- **Surfaces d'administration d'instance gardées** (`hub_roles.admin_hub_ok` : session draco + rôle connu → `administrateur` exigé ; sinon comportement actuel) : `/api/admin/*` (`admin_required`), configuration des fournisseurs de l'agent (`agent._current_admin`), journal d'audit unifié (branche session de `audit._admin_user`), administration SCIM/domaines (branche session de `scim._admin_session`), configuration SSO (branche session de `sso._require_admin`). Les voies Bearer de ces mêmes surfaces (contrats d'API propres) et l'administration d'espace de travail (rôles de membre) ne sont pas touchées.
|
||||
- **Provisionnement JIT inchangé** (HUB-15) : le refus intervient au même point que l'établissement de session, avant toute création.
|
||||
- Tests : `tests/test_hub_roles.py` (nouveau, 28 tests — client : contrat exact de l'appel, cache par `sub`, ancien cache après échec, erreurs → inconnu ; intégration : 403 français au pont, refus avant JIT — compte et lien absents —, rôle `utilisateur` avec JIT intact, repli rôle inconnu, session préexistante exemptée, garde `/api/admin/*` selon le rôle). Balayage draco/SSO/recherche existant reconfirmé sans régression (voir PR).
|
||||
|
||||
## v7.85.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 HUB-15 #51 (Flowdeck 7.85.0) ; branchée sur `main` (7.84.15), elle vise **7.85.1**.
|
||||
|
||||
- **Barre d'apps v0.5.0 vendangée (dracodev-commons, branche `hub18-theme-commun`, PR commons #12)** : seul `static/js/vendor/draco-barre.js` change, remplacé verbatim par la v0.5.0 ; le CSS et le fragment `app/templates/_draco_bar.html` sont inchangés.
|
||||
- **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` : le comportement actuel (choix local, puis système) reste seul maître.
|
||||
- **Initialisation propre à Flowdeck** (`static/js/app.js`) : écoute de l'événement `draco:theme` de la barre — le mode effectif est appliqué sur `data-theme` et mémorisé dans la clé locale `theme` (convergence) ; le sélecteur de thème des Réglages reste fonctionnel pour la session.
|
||||
- **Cache** : le bump 7.85.0 → 7.85.1 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
- Tests : `tests/test_draco_bar_texte.py` étendu (marqueurs v0.5.0 du JS vendangé + écouteur `draco:theme` dans app.js).
|
||||
|
||||
## v7.85.0 (2026-11-06) — HUB-15 : provisionnement JIT des comptes draco (fusion #129)
|
||||
|
||||
- **Généralisation de `maybe_link` en création JIT** (`app/services/draco.py`) : à la première connexion avec un cookie `draco_session` valide, si aucun compte local ne correspond au `sub` (ni lien `draco_links`, ni repli mono-utilisateur), le pendant local est **créé automatiquement** depuis les claims (`sub`, `name`) et lié au `sub` dans la même transaction — aucune création manuelle dans l'app. Un compte de suite créé dans le Hub fonctionne donc dans Flowdeck dès sa première visite.
|
||||
- **Rôle par défaut sûr** : utilisateur standard (`is_admin = 0`, jamais admin), `auth_method = 'draco'`, **aucun mot de passe local** (`password_hash` NULL — la connexion locale par mot de passe reste impossible pour ce compte, et inchangée pour tous les autres). Login déterministe `draco-<sub>` (suffixe d'empreinte en cas de collision avec un login existant non lié).
|
||||
- **Gardes** : une ligne `draco_links` existante pour le `sub` (lien actif, compte inactif ou **pierre tombale** de dissociation) interdit toute création — une dissociation explicite n'est jamais contournée. Les comptes déjà liés ne changent pas ; la connexion locale et l'OAuth Gitea/GitHub sont intacts.
|
||||
- **Numérotation** : fonction nouvelle → série mineure 7.84 → **7.85.0** (patron du dépôt : la série porte les vagues de fonctions, les correctifs restent en patch).
|
||||
- Tests : `tests/test_fusion_draco.py` — création JIT en multi-utilisateurs (rôle, absence de mot de passe, lien, réutilisation à la 2e visite, connexion locale refusée), pierre tombale sans re-création ; l'ancien refus « lien requis en multi-utilisateurs » est remplacé par le nouveau contrat (le lien explicite continue d'ouvrir la porte).
|
||||
|
||||
## v7.84.15 (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)** : seul `static/js/vendor/draco-barre.js` change, remplacé verbatim par la v0.4.1 ; le CSS et le fragment `app/templates/_draco_bar.html` sont inchangés.
|
||||
- **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 7.84.14 → 7.84.15 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
- Tests : `tests/test_draco_bar_texte.py` étendu (création en espace de noms SVG verrouillée).
|
||||
|
||||
## v7.84.14 (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/vendor/draco-barre.js` remplacés verbatim par la v0.4.0 ; le fragment `app/templates/_draco_bar.html` est inchangé (aucune clé d'URL nouvelle).
|
||||
- **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, couleur atténuée suivant le survol) 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 et le relais « Déconnexion » sont inchangés.
|
||||
- **Cache** : le bump 7.84.13 → 7.84.14 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
- Tests : `tests/test_draco_bar_texte.py` étendu (marqueurs v0.4.0 du JS et du CSS vendangés).
|
||||
|
||||
## v7.84.13 (2026-11-04) — Barre commune v0.3.0 : liens Hub du menu compte (HUB-1)
|
||||
|
||||
- **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/vendor/draco-barre.js` remplacés verbatim par la v0.3.0 ; fragment `app/templates/_draco_bar.html` porté (note de vendange + `urls.hub` dans la configuration), le reste de l'intégration Flowdeck inchangé.
|
||||
- **Menu compte** : liens **Télécharger**, **À Propos**, **Aides** et **Profil** (devenu un lien vers `<hub>/profil`, plus un relais) construits par le JS vendangé depuis la base Hub résolue ; « Déconnexion » reste relayée à l'hôte, inchangée.
|
||||
- **URL Hub par le même mécanisme que les autres** : `app/services/draco_urls.py` gagne la clé `hub` (défaut officiel `https://hub.dracodev.net` ; explicite `HUB_URL` / `settings.hub_url` ; dérivation par l'hôte de la requête — motif fermé étendu à `hub` — et par l'hôte de l'agent : `hub-dev` en dev), exposée dans `draco_urls.toutes()` et passée au gabarit en `urls.hub`. Les consommateurs par listes de clés (CORS, CSP, recherche fédérée) ne sont pas affectés : le Hub n'y entre pas.
|
||||
- **Cache** : le bump 7.84.12 → 7.84.13 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
- Tests : `tests/test_draco_urls_devprod.py` (résolution Hub : défaut, dérivations, explicite) et `tests/test_draco_bar_texte.py` (marqueurs v0.3.0 du JS vendangé et du gabarit) étendus.
|
||||
|
||||
## v7.84.11 (2026-11-04) — Recherche fédérée : ShaarIt, 5e source de « Rechercher partout »
|
||||
|
||||
- **`static/js/recherche.js` re-vendangé depuis dracodev-commons v0.2.1** (source canonique `ui/recherche/`, contrat `contrats/recherche-federee.md`) : 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 et route `/recherche`** : onglet et section « ShaarIt — bookmarks » ; la route expose désormais `bases.shaarit` (la clé existait dans `draco_urls.toutes()` depuis 7.84.10) et le gabarit rend `data-base-shaarit`.
|
||||
- **CORS de recherche** (`app/middleware/recherche_cors.py`) : origines officielles ShaarIt prod et dev ajoutées aux listes explicites, `shaarit` ajoutée aux clés sœurs résolues — `GET /api/search` de Flowdeck devient lisible depuis les pages de ShaarIt, et réciproquement.
|
||||
- Tests : `tests/recherche.test.js` porté à 5 sections (83 assertions, dont le normaliseur ShaarIt) ; `tests/test_recherche_onglets.py` (onglets attendus) et `tests/test_recherche.py` (allowlist à 12 origines) mis à jour. Fonds préexistant inchangé (`test_webhooks_crud`, échec confirmé sur main vierge).
|
||||
|
||||
## v7.84.10 (2026-11-03) — Barre commune v0.2.0 : onglet ShaarIt, garde data-theme
|
||||
|
||||
- **Barre d'apps v0.2.0 vendangée (dracodev-commons @70c837a)** : `static/css/draco-barre.css` et `static/js/vendor/draco-barre.js` remplacés verbatim par la v0.2.0 (hachages SHA-256 vérifiés contre la source) ; fragment `app/templates/_draco_bar.html` porté à la v0.2.0, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
|
||||
- **Onglet ShaarIt** : 5e application (bookmarks, réécriture Rust de Shaarli) — nouvel onglet `data-app="shaarit"` après Kanbante et avant l'onglet Console Spark, logo `static/img/logos/shaarit.svg` ajouté tel quel (copie verbatim de `ui/barre-apps/logos/` de commons). Le menu de la marque, construit depuis les onglets, l'inclut automatiquement.
|
||||
- **URL ShaarIt par le même mécanisme que les autres** : `app/services/draco_urls.py` gagne la clé `shaarit` (défaut officiel `https://shaarit.dracodev.net` ; explicite `SHAARIT_URL` / `settings.shaarit_url` ; dérivation par l'hôte de la requête et par l'hôte de l'agent : `shaarit-dev` en dev) et `draco_urls().shaarit` alimente le href de l'onglet — aucune URL codée en dur hors des défauts officiels du résolveur.
|
||||
- **⚠️ Changement de comportement visible — garde `data-theme`** : le CSS v0.2.0 préfixe ses blocs `prefers-color-scheme` par `html:not([data-theme])`. La copie v0.1.11 n'avait pas cette garde : c'est le **piège connu de Flowdeck** — système en mode sombre + `fd_theme` clair (`data-theme="light"` posé) ⇒ la barre s'affichait quand même en sombre. La re-vendange corrige ce point : la barre suit désormais le thème choisi dans l'app. Signalé en tête de la PR pour arbitrage à la fusion.
|
||||
- **Cache** : le bump 7.84.9 → 7.84.10 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
|
||||
## v7.84.9 (2026-10-29) — Barre commune : marque monochrome, anneau rouge avatar
|
||||
|
||||
- **Barre d'apps v0.1.11 vendangée (dracodev-commons)** : `static/css/draco-barre.css` remplacé verbatim par la v0.1.11 ; `static/js/vendor/draco-barre.js` inchangé (déjà identique à commons, JS inchangé en v0.1.11) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.11, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
|
||||
- **Marque monochrome** : la marque n'est plus le logo rouge — c'est l'étoile Spark monochrome (`static/img/brand-spark.png`, copie de `ui/barre-apps/brand-spark.png` de commons, étoile blanche sur fond transparent, 26 px), blanche sur barre foncée et 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 avatar** : le bouton avatar flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px du CSS vendangé, `var(--agent)`).
|
||||
- **Thème** : la barre suit `data-theme` sur `<html>` (choix Flowdeck `fd_theme`, défaut light) — les règles thème de commons s'appliquent telles quelles, aucune surcharge propre n'a été ajoutée.
|
||||
- **Cache** : le bump 7.84.8 → 7.84.9 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
|
||||
## v7.84.8 (2026-10-29) — Barre commune : rouge Spark, avatar flottant, Alertes cloche seule
|
||||
|
||||
- **Barre d'apps v0.1.10 vendangée (dracodev-commons)** : `static/css/draco-barre.css` remplacé verbatim par la v0.1.10 ; `static/js/vendor/draco-barre.js` inchangé (déjà identique à commons entre v0.1.9 et v0.1.10) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.10, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
|
||||
- **Rouge Spark** : `--agent` rose `#c2437e` → rouge `#c00000` (échantillonné dans le logo Spark) ; onglet agent `--app #c00000`, marque = logo Spark (26 px) au lieu du ✦.
|
||||
- **Bouton Spark rose supprimé** de la barre, remplacé par le bouton avatar flottant bas-centre (cercle personnage Spark, `static/img/avatar-spark.png`, `data-act="spark"` → le panneau Spark de l'hôte, comme l'ancien bouton).
|
||||
- **Alertes** : cloche seule, sans libellé — bouton moins large.
|
||||
- **Cache** : le bump 7.84.7 → 7.84.8 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
|
||||
## v7.84.7 (2026-10-29) — Barre commune : les 5 logos dans le menu et la barre
|
||||
|
||||
- **Barre d'apps v0.1.9 vendangée (dracodev-commons)** : `static/js/vendor/draco-barre.js` et `static/css/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 `app/templates/_draco_bar.html` porté à la v0.1.9, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP) est inchangé.
|
||||
- **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 ✦ et le bouton Spark gardent leur ✦).
|
||||
- **Cache** : le bump 7.84.6 → 7.84.7 change aussi `asset_version`, donc les navigateurs ne garderont pas les anciennes icônes.
|
||||
|
||||
## v7.84.6 (2026-10-28) — Recherche console : CORS Flowdeck sans doublons (PR #41)
|
||||
|
||||
- **CORS recherche (PR #41)** : le middleware de recherche remplace désormais les en-têtes CORS existants au lieu de les empiler — le CORSMiddleware global posait déjà Allow-Credentials: true, l'ancien middleware ajoutait les siens par-dessus (« true, true » rejeté par le navigateur) ; l'onglet Flowdeck de la recherche console agent-dev n'est plus injoignable.
|
||||
|
||||
## v7.84.5 (2026-10-28) — Corrections console/recherche : texte parasite, onglets, CORS console
|
||||
|
||||
- **Barre (PR #37)** : suppression du texte parasite « Chantier 5 » rendu au-dessus du header.
|
||||
- **Recherche (PR #38)** : onglets visibles après déploiement (base console explicite + purge cache service worker).
|
||||
- **CORS recherche (PR #40)** : la recherche Flowdeck autorise désormais la console Spark (agent prod+dev) en inter-origines.
|
||||
|
||||
## v7.84.4 (2026-10-28) — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
|
||||
|
||||
## v7.84.3 (2026-10-28) — Corrections recherche/alertes : CORS prod+dev, recherche en onglets, alertes en onglets
|
||||
|
||||
- **CORS recherche/alertes (#68/#69)** : allowlist d'origines soeurs prod+dev explicites + credentials, jamais `*` — chaque serveur n'autorisait que l'environnement resolu, d'ou les echecs inter-apps constates.
|
||||
- **Recherche partout (#68)** : recherche pleine largeur, reintegree dans le gabarit, focus auto, « Tout voir » restant sur place, onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark.
|
||||
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark/Historique, actives sauf Historique = fermees), filtre texte, composant partage vendange byte-identique ; bouton Console Spark en 5e onglet de la barre apres Kanbante.
|
||||
|
||||
## v7.84.2 (2026-10-28) — Programme Fusion : menu barre, alertes unifiées, recherche partout
|
||||
|
||||
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CSP/CORS bornées à `/api/notifications`.
|
||||
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), draco + 401 sur `/api/search` (corrige la recherche sans périmètre sans session) ; la palette de la barre ouvre désormais `/recherche`.
|
||||
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.84.1 (2026-10-28) — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
||||
|
||||
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||
|
||||
## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4)
|
||||
|
||||
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
|
||||
|
||||
@@ -156,6 +156,8 @@ class Settings(BaseSettings):
|
||||
# DRACO_AGENT_URL=https://agent-dev.dracodev.net
|
||||
# TIKTAK_URL=https://tiktak-dev.dracodev.net
|
||||
# KANBANTE_URL=https://kanbante-dev.dracodev.net
|
||||
# SHAARIT_URL=https://shaarit-dev.dracodev.net
|
||||
# HUB_URL=https://hub-dev.dracodev.net
|
||||
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
|
||||
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
|
||||
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue #22) ──
|
||||
@@ -168,6 +170,8 @@ class Settings(BaseSettings):
|
||||
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
|
||||
tiktak_url: str = "https://tiktak.dracodev.net"
|
||||
kanbante_url: str = "https://kanbante.dracodev.net"
|
||||
shaarit_url: str = "https://shaarit.dracodev.net"
|
||||
hub_url: str = "https://hub.dracodev.net"
|
||||
# URL publique de CETTE instance ; vide = dérivée de
|
||||
# OAUTH_REDIRECT_URI, puis d'APP_BASE_URL (si non locale), puis du
|
||||
# nom officiel.
|
||||
|
||||
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
||||
|
||||
|
||||
# Littéral de version synchronisé avec le fichier VERSION :
|
||||
# version="7.84.0"
|
||||
# version="7.85.2"
|
||||
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
||||
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||
@@ -239,6 +239,29 @@ app.add_middleware(RateLimitMiddleware)
|
||||
_CORS_ORIGINS = sorted(
|
||||
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
|
||||
)
|
||||
# Fusion #69 : le panneau Alertes unifiées lit /api/notifications
|
||||
# depuis les hôtes soeurs, avec credentials — leurs origines doivent
|
||||
# donc figurer dans allow_origins (résolution draco_urls : explicite
|
||||
# > hôte de la requête > dérivation > défaut officiel ; hors requête,
|
||||
# la configuration seule). Lecture seule (GET) ; les POST restent
|
||||
# soumis au CSRFMiddleware.
|
||||
from app.services import draco_urls as _draco_urls69
|
||||
_toutes69 = _draco_urls69.toutes()
|
||||
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | {
|
||||
_draco_urls69.origine(_toutes69[k])
|
||||
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
} - {""})
|
||||
# Correctif CORS alertes (fusion #69, 2026-10-28) : allow_origins
|
||||
# est figé à l'import (configuration seule, sans hôte de requête)
|
||||
# — il ne doit donc pas dépendre de l'environnement résolu. Les
|
||||
# origines sœurs prod ET dev sont listées explicitement (module
|
||||
# CORS de la recherche, jamais "*") : c'est ce qui manquait à
|
||||
# /api/notifications lu depuis les autres hôtes.
|
||||
from app.middleware.recherche_cors import DEV_ORIGINS as _DEV_ORIGINS
|
||||
from app.middleware.recherche_cors import PROD_ORIGINS as _PROD_ORIGINS
|
||||
|
||||
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | set(_DEV_ORIGINS)
|
||||
| set(_PROD_ORIGINS))
|
||||
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
||||
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
||||
app.add_middleware(
|
||||
@@ -250,6 +273,13 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
|
||||
# après CORSMiddleware donc exécutée avant lui (dernier ajouté = le
|
||||
# plus externe) : les preflights OPTIONS sont répondus ici, avant
|
||||
# tout refus d'origine. La politique CORS globale est inchangée.
|
||||
from app.middleware.recherche_cors import RechercheCorsMiddleware
|
||||
|
||||
app.add_middleware(RechercheCorsMiddleware)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sso_router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
|
||||
|
||||
La page « Rechercher partout » (``/recherche``) appelle, depuis le
|
||||
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
|
||||
en-têtes CORS, le navigateur refuse la lecture des réponses
|
||||
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
|
||||
Ce module n'autorise donc que :
|
||||
|
||||
- le chemin ``/api/search`` (jamais les autres routes, jamais les
|
||||
écritures) ;
|
||||
- les origines sœurs : les origines officielles **prod ET dev,
|
||||
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
|
||||
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
|
||||
ET dev — fédération de la recherche depuis la console,
|
||||
2026-10-28), **plus**
|
||||
celles résolues par ``app.services.draco_urls.toutes()``
|
||||
(explicite > hôte > dérivation > défaut officiel) — jamais ``*``.
|
||||
Correctif 2026-10-28 (fusion #68/#69, matrice constatée par
|
||||
Bruno) : l'ancienne allowlist ne contenait que l'environnement
|
||||
résolu (configuration seule, ce middleware s'exécutant avant le
|
||||
middleware CSP qui pose le contexte d'hôte, plus l'environnement
|
||||
de l'hôte de la requête) — jamais les deux à la fois. Une page
|
||||
servie dans l'autre environnement voyait donc sa section
|
||||
afficher « Injoignable depuis cet hôte (CORS ou réseau) », et
|
||||
c'est Flowdeck qui échouait depuis tous les hôtes ;
|
||||
- avec credentials (le cookie SSO ``draco_session``, accepté par
|
||||
``/api/search`` depuis cette livraison, ou la session native).
|
||||
|
||||
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
|
||||
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
routes ni à la politique CORS globale de l'application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SEARCH_PATH = "/api/search"
|
||||
|
||||
PROD_ORIGINS = frozenset({
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://shaarit.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
"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",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
})
|
||||
|
||||
|
||||
# Clés de draco_urls.toutes() qui sont des applications sœurs.
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 4 and first.endswith("-dev")
|
||||
|
||||
|
||||
def is_prod_host(hostname: str | None) -> bool:
|
||||
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 0 and not first.endswith("-dev")
|
||||
|
||||
|
||||
def origin_of(url: str | None) -> str | None:
|
||||
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return None
|
||||
host = parsed.hostname.lower()
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return f"{parsed.scheme}://{host}{port}"
|
||||
|
||||
|
||||
def sister_origins(resolved: dict | None = None, request_host: str | None = None) -> frozenset:
|
||||
"""Origines autorisées pour la lecture cross-origin de /api/search.
|
||||
|
||||
``resolved`` : dictionnaire rendu par ``draco_urls.toutes()``.
|
||||
Sans argument, le résolveur est appelé (import paresseux) ; en cas
|
||||
d'échec, repli sur prod + dev officiels (premier parti uniquement).
|
||||
``request_host`` ajoute l'ensemble officiel de l'environnement de
|
||||
la requête (voir le docstring du module).
|
||||
"""
|
||||
origins = set()
|
||||
if resolved is None:
|
||||
try:
|
||||
from app.services import draco_urls
|
||||
|
||||
resolved = draco_urls.toutes()
|
||||
except Exception:
|
||||
resolved = {}
|
||||
for key in _SOEUR_KEYS:
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
|
||||
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
|
||||
# l'environnement de la requête, désormais inclus d'office).
|
||||
# ``resolved`` n'ajoute que des origines de configuration sans
|
||||
# jamais retirer une origine officielle.
|
||||
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
|
||||
if not origin:
|
||||
return None
|
||||
if origin.strip() not in sister_origins(resolved, request_host):
|
||||
return None
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin.strip(),
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
|
||||
|
||||
def preflight_headers(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
|
||||
headers = cors_headers_for(origin, resolved, request_host)
|
||||
if headers is None:
|
||||
return None
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
class RechercheCorsMiddleware:
|
||||
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
headers_in = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
origin = headers_in.get("origin", "")
|
||||
request_host = (
|
||||
headers_in.get("x-forwarded-host", "") or headers_in.get("host", "")
|
||||
).split(",")[0].strip()
|
||||
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
|
||||
preflight = preflight_headers(origin, request_host=request_host)
|
||||
if preflight is not None:
|
||||
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in preflight.items()]
|
||||
headers_out.append((b"content-length", b"0"))
|
||||
await send({"type": "http.response.start", "status": 204,
|
||||
"headers": headers_out})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return
|
||||
if path != SEARCH_PATH:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_cors(message):
|
||||
if message["type"] == "http.response.start":
|
||||
cors = cors_headers_for(origin, request_host=request_host)
|
||||
if cors is not None:
|
||||
# Remplacer, pas empiler (défaut 3b, 2026-10-28) :
|
||||
# le CORSMiddleware global de l'application pose
|
||||
# déjà Access-Control-Allow-Credentials: true sur
|
||||
# TOUTES les réponses (comportement Starlette avec
|
||||
# allow_credentials=True, même pour une origine
|
||||
# hors allowlist et hors /api/search) ; empiler les
|
||||
# en-têtes de recherche donnait, sur /api/search
|
||||
# depuis agent-dev, Allow-Credentials en double
|
||||
# (« true, true » ≠ « true » pour le contrôle CORS
|
||||
# du navigateur → fetch rejeté → « injoignable
|
||||
# (CORS ou réseau) ») et, depuis une sœur
|
||||
# (origine déjà dans la allowlist globale),
|
||||
# Allow-Origin en double (rejet certain). Les
|
||||
# hôtes TikTak/Kanbante, sans ce cumul, répondent
|
||||
# avec des en-têtes uniques — d'où l'échec
|
||||
# Flowdeck seul. Une origine non autorisée
|
||||
# traverse sans modification (comportement
|
||||
# antérieur préservé : le seul en-tête global
|
||||
# reste, sans Allow-Origin, donc bloqué).
|
||||
restants = [
|
||||
(k, v) for k, v in message.get("headers", [])
|
||||
if k.decode("latin-1").lower() not in (
|
||||
"access-control-allow-origin",
|
||||
"access-control-allow-credentials",
|
||||
"vary")]
|
||||
message["headers"] = restants + [
|
||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in cors.items()
|
||||
]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cors)
|
||||
@@ -139,7 +139,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# l'agent connu en config ; défaut = nom officiel).
|
||||
# Même origine pour frame-src : la console du service est
|
||||
# encadrée en iframe par le panneau (surimpression v3).
|
||||
"connect-src 'self' {agent} ws://{host} wss://{host}; "
|
||||
"connect-src 'self' {agent} {sisters} ws://{host} wss://{host}; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self' {agent}; "
|
||||
"object-src 'none'; "
|
||||
@@ -174,8 +174,24 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
request.headers.get("host", ""))
|
||||
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
||||
draco_urls.origine(draco_urls.agent_url()))
|
||||
# Fusion #69 : le panneau Alertes unifiées lit les API
|
||||
# notifications des applications soeurs depuis cet hôte ;
|
||||
# leurs origines rejoignent connect-src (même résolution
|
||||
# draco_urls que les liens de la barre et frame-src).
|
||||
# Fusion #111 : la recherche fédérée appelle en plus
|
||||
# l'API de ShaarIt depuis la page /recherche — sans son
|
||||
# origine ici, le navigateur bloque l'appel et la section
|
||||
# ShaarIt affichait « Injoignable depuis cet hôte (CORS ou
|
||||
# réseau) » alors que le CORS de ShaarIt était correct
|
||||
# (retour d'usage du 2026-11-04).
|
||||
_toutes69 = draco_urls.toutes()
|
||||
sisters = " ".join(sorted({
|
||||
draco_urls.origine(_toutes69[k])
|
||||
for k in ("obsigate", "flowdeck", "tiktak", "kanbante",
|
||||
"shaarit")
|
||||
} - {""}))
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
||||
nonce=nonce, host=host, agent=agent
|
||||
nonce=nonce, host=host, agent=agent, sisters=sisters
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ async def admin_required(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# HUB-19 (registre des rôles du Hub) : session draco + rôle connu
|
||||
# pour Flowdeck → exiger « administrateur » dans le registre,
|
||||
# en plus du drapeau local. Pas de session draco ou rôle inconnu
|
||||
# (Hub injoignable) : comportement actuel.
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -127,6 +127,14 @@ def _current_admin(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (voir app.services.hub_roles.admin_hub_ok).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ def _admin_user(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
|
||||
(sess.get("id"),)).fetchone()
|
||||
if row and row["is_admin"]:
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger
|
||||
# « administrateur » dans le registre du Hub (la voie
|
||||
# Bearer ci-dessous, contrat d'API propre, est exemptée).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
403, "Niveau « administrateur » requis dans le "
|
||||
"registre des rôles du Hub pour FlowDeck")
|
||||
return sess
|
||||
raise HTTPException(403, "Admin required")
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
|
||||
@@ -483,10 +483,39 @@ def draco_bridge(request: Request, next: str = "/workspaces"):
|
||||
Point de retour de ``GET /sso/authorize`` de l'émettrice : si le
|
||||
cookie est valide et résolu vers un compte local, une session
|
||||
Flowdeck native est créée ; sinon, retour à la connexion locale.
|
||||
|
||||
HUB-19 (registre des rôles du Hub, contrat D3(a)) : rôle connu
|
||||
``aucun`` pour Flowdeck → refus 403 en français nommant le
|
||||
registre du Hub, **avant** la création de la session locale et
|
||||
avant tout provisionnement JIT (aucun compte créé pour un
|
||||
``sub`` refusé). Exemption, patron du pilote ShaarIt : une
|
||||
session locale préexistante (accès de secours) n'est pas
|
||||
refusée — le pont la laisse simplement se poursuivre. Rôle
|
||||
inconnu (Hub injoignable/erreur) : comportement actuel.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.services import draco
|
||||
|
||||
user = draco.user_for_draco(request)
|
||||
_cookie_session = request.cookies.get("flowdeck_session", "")
|
||||
session_existante = (SessionManager.decode_session(_cookie_session)
|
||||
if _cookie_session else None)
|
||||
try:
|
||||
user = draco.user_for_draco(
|
||||
request, refuser_aucun=not session_existante)
|
||||
except draco.RoleAucunRefuse:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8">
|
||||
<title>Accès refusé — FlowDeck</title></head><body><main>
|
||||
<h1>Accès refusé</h1>
|
||||
<p>Le registre des rôles de la suite (Hub Dracodev) attribue le niveau
|
||||
« aucun » à votre compte pour FlowDeck : l'accès par la session draco
|
||||
est refusé. L'administrateur de suite peut modifier ce niveau dans la
|
||||
matrice des rôles du Hub. L'accès de secours par le mot de passe local
|
||||
de l'instance reste possible par la page de connexion.</p>
|
||||
</main></body></html>""",
|
||||
status_code=403,
|
||||
)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
session = SessionManager.create_session(user, request)
|
||||
|
||||
@@ -46,6 +46,14 @@ def _admin_session(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (les jetons SCIM Bearer, contrat propre,
|
||||
# passent par _scim_guard et sont exemptés).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
403, "Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
||||
editor pages and databases scoped to the current user's accessible workspaces.
|
||||
|
||||
Recherche partout (fusion-trois-apps #68) : la même route sert la page
|
||||
unifiée (``GET /recherche``, coquille dont les résultats sont chargés
|
||||
côté client) et accepte, en plus de la session native, le jeton SSO
|
||||
``draco_session`` (validateur local ``app.services.draco``). Sans
|
||||
session ni jeton valide, 401 — avant cette livraison, l'absence de
|
||||
session donnait une recherche SANS périmètre (``user_id=None`` → ``1=1``
|
||||
dans ``_scope_where``) : corrigé ici.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.search import search as search_service
|
||||
@@ -17,6 +26,18 @@ router = APIRouter(tags=["search"])
|
||||
def search(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user_id = user.get("id") if user and user.get("id") else None
|
||||
if user_id is None:
|
||||
# Repli SSO draco_session : un jeton valide désigne l'utilisateur
|
||||
# lié, ou l'unique utilisateur actif (cas mono-utilisateur).
|
||||
try:
|
||||
from app.services import draco as draco_service
|
||||
|
||||
draco_user = draco_service.user_for_draco(request)
|
||||
user_id = draco_user.get("id") if draco_user else None
|
||||
except Exception:
|
||||
user_id = None
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
data = search_service(q, user_id=user_id)
|
||||
return {
|
||||
@@ -25,3 +46,37 @@ def search(request: Request, q: str = Query(default="")):
|
||||
"collections": data["collections"],
|
||||
"total": len(data["pages"]) + len(data["collections"]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recherche", response_class=HTMLResponse)
|
||||
def recherche_page(request: Request, q: str = Query(default="")):
|
||||
"""Page de résultats unifiée « Rechercher partout » (fusion #68).
|
||||
|
||||
Coquille seule : les résultats sont chargés côté client par
|
||||
static/js/recherche.js (API de recherche de chaque app, en
|
||||
parallèle, états par application). La palette Ctrl+K native et
|
||||
``GET /api/search`` restent inchangés par ailleurs.
|
||||
|
||||
HUB-21 : le drapeau ``recherche_partout`` des réglages
|
||||
généraux du Hub est rendu dans l'attribut
|
||||
``data-recherche-partout`` de la coquille (cache serveur de
|
||||
60 s de ``app.services.hub_reglages`` ; Hub muet → « true »,
|
||||
comportement actuel) — quand il est faux, recherche.js
|
||||
n'interroge que la section locale Flowdeck.
|
||||
"""
|
||||
from app.services import draco_urls, hub_reglages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
bases = draco_urls.toutes()
|
||||
template = ENV.get_template("recherche.html")
|
||||
# Defaut 6 : la base agent est exposee explicitement (data-base-console
|
||||
# dans recherche.html), comme les 3 autres hotes — recherche.js ne doit
|
||||
# plus la deviner (derivation -dev / href d'onglet de barre) pour
|
||||
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
|
||||
return HTMLResponse(template.render(
|
||||
request=request, q=q.strip(), user=user,
|
||||
recherche_partout=hub_reglages.recherche_partout(),
|
||||
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
|
||||
"kanbante", "shaarit", "agent")},
|
||||
))
|
||||
|
||||
@@ -527,6 +527,15 @@ def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (la voie Bearer ci-dessus, contrat d'API
|
||||
# propre, est exemptée).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
|
||||
@@ -15,6 +15,23 @@ local : table ``draco_links`` (migration 51), créée automatiquement
|
||||
coexistent (association 1:1, dissociation = pierre tombale) ; à
|
||||
défaut de lien, une instance à un seul utilisateur actif associe ce
|
||||
sub à cet unique compte. Sans secret configuré, module inerte.
|
||||
|
||||
HUB-15 (plan Hub v2.1, §2.2) — provisionnement JIT : si aucun compte
|
||||
local ne correspond au ``sub`` (ni lien, ni repli mono-utilisateur),
|
||||
le pendant local est **créé automatiquement** à la première
|
||||
connexion draco, depuis les claims (``sub``, ``name``), avec le rôle
|
||||
par défaut sûr : utilisateur standard (``is_admin = 0``, jamais
|
||||
admin), aucun mot de passe local (``password_hash`` NULL,
|
||||
``auth_method = 'draco'`` — l'authentification passe par draco, la
|
||||
connexion locale par mot de passe reste impossible pour ce compte
|
||||
et inchangée pour les autres). Une pierre tombale (dissociation
|
||||
explicite) interdit toute re-création automatique pour ce ``sub``.
|
||||
|
||||
HUB-19 (registre des rôles du Hub) : le pont d'ouverture de session
|
||||
peut demander le refus d'un ``sub`` au rôle connu ``aucun``
|
||||
(``user_for_draco(..., refuser_aucun=True)`` →
|
||||
``RoleAucunRefuse``), levé avant la résolution de compte et le
|
||||
provisionnement JIT — voir ``app.services.hub_roles``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,6 +51,16 @@ logger = logging.getLogger(__name__)
|
||||
COOKIE_NAME = "draco_session"
|
||||
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
|
||||
|
||||
|
||||
class RoleAucunRefuse(Exception):
|
||||
"""HUB-19 : le registre des rôles du Hub attribue le niveau
|
||||
``aucun`` à ce ``sub`` pour Flowdeck — l'ouverture de session
|
||||
draco est refusée (403), avant tout provisionnement JIT."""
|
||||
|
||||
def __init__(self, sub: str):
|
||||
super().__init__(sub)
|
||||
self.sub = sub
|
||||
|
||||
_revoked_cache: dict[str, int] = {}
|
||||
_revoked_fetched_at: float = 0.0
|
||||
|
||||
@@ -138,8 +165,89 @@ def _linked_user(conn, sub: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def user_for_draco(request) -> dict | None:
|
||||
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None."""
|
||||
def _jit_login(sub: str) -> str:
|
||||
"""Login local déterministe dérivé du ``sub`` draco.
|
||||
|
||||
Préfixe ``draco-`` (aucune collision possible avec un login
|
||||
choisi par un humain ou par l'OAuth, préfixés par fournisseur),
|
||||
caractères restreints à ``[a-z0-9._-]``.
|
||||
"""
|
||||
nettoye = "".join(
|
||||
c if (c.isascii() and (c.isalnum() or c in "._-")) else "-"
|
||||
for c in sub.strip().lower()
|
||||
).strip("-")
|
||||
return f"draco-{nettoye}" if nettoye else "draco-compte"
|
||||
|
||||
|
||||
def _provision_jit(conn, claims: dict) -> dict | None:
|
||||
"""Crée le pendant local d'un ``sub`` draco inconnu (HUB-15).
|
||||
|
||||
Création + liaison dans la même transaction. Retourne le nouvel
|
||||
utilisateur, ou None si la création ne doit pas avoir lieu :
|
||||
une ligne ``draco_links`` existe déjà pour ce ``sub`` (lien actif
|
||||
— déjà résolu par ``_linked_user`` —, compte lié inactif, ou
|
||||
pierre tombale d'une dissociation explicite : on ne recrée
|
||||
jamais automatiquement derrière une dissociation).
|
||||
"""
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
deja = conn.execute(
|
||||
"SELECT user_id FROM draco_links WHERE sub = ?", (sub,)
|
||||
).fetchone()
|
||||
if deja is not None:
|
||||
return None
|
||||
nom = str(claims.get("name", "") or "").strip()
|
||||
login = _jit_login(sub)
|
||||
# Collision résiduelle : un login identique pris par un autre
|
||||
# compte (non lié) — on suffixe par une empreinte courte du sub
|
||||
# plutôt que de lier ou d'écraser quoi que ce soit.
|
||||
if conn.execute(
|
||||
"SELECT id FROM users WHERE login = ?", (login,)
|
||||
).fetchone() is not None:
|
||||
suffixe = hashlib.sha256(sub.encode("utf-8")).hexdigest()[:8]
|
||||
login = f"{login}-{suffixe}"
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, "
|
||||
"is_admin, is_active, auth_method) "
|
||||
"VALUES (?, ?, '', NULL, 0, 1, 'draco')",
|
||||
(login, nom or login),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
|
||||
(sub, cur.lastrowid),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # la création ne casse jamais la requête
|
||||
logger.debug("Provisionnement JIT draco refusé: %s", exc)
|
||||
conn.rollback()
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
logger.info("Compte local créé en JIT pour le sub draco %s (login %s)",
|
||||
sub, login)
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def user_for_draco(request, refuser_aucun: bool = False) -> dict | None:
|
||||
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None.
|
||||
|
||||
Résolution : lien ``draco_links`` existant, sinon repli
|
||||
mono-utilisateur, sinon provisionnement JIT (HUB-15) — le
|
||||
pendant local est créé depuis les claims et lié au ``sub``.
|
||||
|
||||
HUB-19 (registre des rôles du Hub, contrat D3(a)) : avec
|
||||
``refuser_aucun=True`` — le pont d'ouverture de session
|
||||
``GET /auth/draco``, seul appelant qui crée une session locale —
|
||||
un rôle **connu** ``aucun`` dans le registre du Hub lève
|
||||
``RoleAucunRefuse`` **avant** toute résolution de compte et donc
|
||||
avant tout provisionnement JIT : aucun compte n'est créé pour un
|
||||
``sub`` refusé. Rôle inconnu (Hub injoignable/erreur) : repli,
|
||||
comportement actuel inchangé. Les autres appelants (recherche
|
||||
fédérée) gardent le défaut ``False`` : aucun refus nouveau.
|
||||
"""
|
||||
if not enabled():
|
||||
return None
|
||||
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
|
||||
@@ -151,8 +259,16 @@ def user_for_draco(request) -> dict | None:
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
if refuser_aucun:
|
||||
from app.services import hub_roles
|
||||
|
||||
if hub_roles.role_pour(
|
||||
request.headers.get("cookie", ""), sub) == "aucun":
|
||||
raise RoleAucunRefuse(sub)
|
||||
with get_conn() as conn:
|
||||
user = _linked_user(conn, sub)
|
||||
if user is None:
|
||||
user = _provision_jit(conn, claims)
|
||||
if user is not None and not user.get("is_active"):
|
||||
return None
|
||||
return user
|
||||
|
||||
@@ -12,6 +12,7 @@ car il est passé par ``environment:``/``env_file:``) :
|
||||
DRACO_AGENT_URL=https://agent-dev.dracodev.net
|
||||
TIKTAK_URL=https://tiktak-dev.dracodev.net
|
||||
KANBANTE_URL=https://kanbante-dev.dracodev.net
|
||||
SHAARIT_URL=https://shaarit-dev.dracodev.net
|
||||
FLOWDECK_URL=https://flowdeck-dev.dracodev.net (ou OAUTH_REDIRECT_URI)
|
||||
|
||||
Priorité de résolution, à chaque appel (les tests et les modules qui
|
||||
@@ -19,15 +20,16 @@ lisaient déjà ``os.environ`` au moment de l'appel gardent ce
|
||||
comportement) : variable d'environnement > ``app.config.settings`` >
|
||||
défaut officiel. Aucun défaut ne contient ``-dev``.
|
||||
|
||||
**Dérivation d'environnement (v7.82.3)** — pour les quatre URL
|
||||
sœurs (ObsiGate, Flowdeck, TikTak, Kanbante), entre la configuration
|
||||
**Dérivation d'environnement (v7.82.3)** — pour les URL
|
||||
sœurs (ObsiGate, Flowdeck, TikTak, Kanbante, et ShaarIt depuis la
|
||||
barre v0.2.0), entre la configuration
|
||||
explicite et le défaut officiel : si l'origine de l'agent telle que
|
||||
l'app la connaît déjà (``AGENT_EVENEMENTS_URL``, via
|
||||
``agent_evenements_url()``) a un hôte de la forme
|
||||
``agent<SUFFIXE>.dracodev.net`` (SUFFIXE éventuellement vide,
|
||||
typiquement ``-dev``), l'URL sœur est dérivée :
|
||||
``https://<slug><SUFFIXE>.dracodev.net`` (slugs : ``og``,
|
||||
``flowdeck``, ``tiktak``, ``kanbante``). Ainsi une installation dev
|
||||
``flowdeck``, ``tiktak``, ``kanbante``, ``shaarit``). Ainsi une installation dev
|
||||
qui ne déclare que l'agent pointe ses liens inter-apps en ``-dev``
|
||||
sans quatre variables de plus. Si l'hôte de l'agent ne correspond
|
||||
pas au motif (autre domaine, LAN, IP), **aucune dérivation** : on
|
||||
@@ -94,7 +96,9 @@ OFFICIELLES = {
|
||||
"agent": "https://agent.dracodev.net",
|
||||
"tiktak": "https://tiktak.dracodev.net",
|
||||
"kanbante": "https://kanbante.dracodev.net",
|
||||
"shaarit": "https://shaarit.dracodev.net",
|
||||
"flowdeck": "https://flowdeck.dracodev.net",
|
||||
"hub": "https://hub.dracodev.net",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
@@ -119,6 +123,8 @@ _SLUGS_SOEURS = {
|
||||
"flowdeck": "flowdeck",
|
||||
"tiktak": "tiktak",
|
||||
"kanbante": "kanbante",
|
||||
"shaarit": "shaarit",
|
||||
"hub": "hub",
|
||||
}
|
||||
|
||||
# Hôte de l'agent : agent<SUFFIXE>.dracodev.net (SUFFIXE = ``-dev``…).
|
||||
@@ -129,7 +135,7 @@ _MOTIF_HOTE_AGENT = re.compile(r"^agent(-[a-z0-9-]+)?\.dracodev\.net$")
|
||||
# de TOUTE l'installation — jamais un suffixe inventé depuis un
|
||||
# hôte arbitraire : hors motif (autre domaine, LAN, IP), None.
|
||||
_MOTIF_HOTE_PUBLIC = re.compile(
|
||||
r"^(og|flowdeck|tiktak|kanbante|agent)(-[a-z0-9-]+)?\.dracodev\.net$")
|
||||
r"^(og|flowdeck|tiktak|kanbante|agent|hub)(-[a-z0-9-]+)?\.dracodev\.net$")
|
||||
|
||||
# Slugs pour la résolution par l'hôte de la requête (l'agent inclus).
|
||||
_SLUGS_TOUTES = {**_SLUGS_SOEURS, "agent": "agent"}
|
||||
@@ -274,6 +280,18 @@ def kanbante_url() -> str:
|
||||
return _resoudre_soeur("KANBANTE_URL", "kanbante_url", "kanbante")
|
||||
|
||||
|
||||
def shaarit_url() -> str:
|
||||
return _resoudre_soeur("SHAARIT_URL", "shaarit_url", "shaarit")
|
||||
|
||||
|
||||
def hub_url() -> str:
|
||||
"""Hub (barre v0.3.0, HUB-1) : même résolution que les sœurs —
|
||||
il n'a pas d'onglet, sa base sert les liens du menu compte
|
||||
(Télécharger, À Propos, Aides, Profil) via ``urls.hub`` de la
|
||||
configuration de la barre."""
|
||||
return _resoudre_soeur("HUB_URL", "hub_url", "hub")
|
||||
|
||||
|
||||
def gitea_url() -> str:
|
||||
return _resoudre("GITEA_URL", "gitea_url", "gitea")
|
||||
|
||||
@@ -356,7 +374,9 @@ def toutes() -> dict[str, str]:
|
||||
"agent": agent_url(),
|
||||
"tiktak": tiktak_url(),
|
||||
"kanbante": kanbante_url(),
|
||||
"shaarit": shaarit_url(),
|
||||
"flowdeck": flowdeck_url(),
|
||||
"hub": hub_url(),
|
||||
"gitea": gitea_url(),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Réglages généraux de la suite — consommation du Hub (HUB-21).
|
||||
|
||||
Propagation du pilote TikTak (0.61.22, PR ticktick-clone #244) à
|
||||
Flowdeck, sur le contrat figé du Hub 0.16.0 : ``GET
|
||||
{hub}/api/reglages/effectifs`` → 200 JSON
|
||||
``{"recherche_partout": bool, "banniere": string ("" = aucune),
|
||||
"domaine_defaut": "pro"|"famille"|"perso"|null,
|
||||
"ttl_secondes": 60}``, ``Cache-Control: public, max-age=60`` —
|
||||
endpoint public, aucun cookie relayé.
|
||||
|
||||
Petit client serveur sur le patron du pilote et du client des
|
||||
rôles (``app.services.hub_roles``) : cache mémoire 60 s,
|
||||
horodatage posé **avant** l'appel — un échec ne déclenche pas de
|
||||
rafale et l'ancien cache reste en vigueur. L'URL du Hub est
|
||||
résolue comme la barre commune (``app.services.draco_urls.hub_url()``
|
||||
: variable explicite ``HUB_URL`` / réglage, dérivation par l'hôte
|
||||
de la requête, puis par l'hôte sœur de l'agent, puis défaut
|
||||
officiel) — jamais d'URL dev figée. Consultation côté serveur,
|
||||
délai court de 2 s, jamais au démarrage (premier appel paresseux,
|
||||
au premier rendu de gabarit).
|
||||
|
||||
**Repli obligatoire** : Hub muet, en erreur ou réponse de forme
|
||||
inattendue → valeurs par défaut actuelles (pas de bannière,
|
||||
recherche partout active, aucun domaine par défaut) —
|
||||
comportement d'aujourd'hui à l'identique. Ce module ne lève
|
||||
jamais.
|
||||
|
||||
**Périmètre Flowdeck (cette PR)** :
|
||||
- ``banniere`` : bandeau d'annonce dans le gabarit principal
|
||||
(``app/templates/base.html``, sous la barre commune), exposé
|
||||
aux gabarits par ``app.templating`` (fonction, pas un
|
||||
instantané : chaque rendu lit le cache de 60 s) ; texte échappé
|
||||
par l'autoescape Jinja, masquage de session navigateur.
|
||||
- ``recherche_partout`` : la route ``GET /recherche`` rend
|
||||
l'attribut ``data-recherche-partout`` de la coquille ; quand le
|
||||
drapeau est faux, ``recherche.js`` (copie vendangée, même
|
||||
modification dans chaque hôte) n'interroge plus les
|
||||
applications sœurs ni la console — la recherche locale
|
||||
Flowdeck (sa propre section, même origine) continue.
|
||||
- ``domaine_defaut`` : **non consommé** — Flowdeck n'a pas de
|
||||
notion de domaine (Pro/Famille/Perso) propre : ses vues ne sont
|
||||
filtrées par aucun domaine ; le sélecteur de la barre commune
|
||||
pilote le panneau Spark, pas Flowdeck. Le réglage est lu et
|
||||
validé par le client, mais pas appliqué ici.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub.
|
||||
CACHE_TTL = 60.0 # secondes
|
||||
|
||||
#: Délai de consultation du Hub : court par contrat (2 s).
|
||||
DELAI = 2.0 # secondes
|
||||
|
||||
#: Valeurs par défaut actuelles (repli quand le Hub ne répond pas).
|
||||
DEFAUTS: dict = {
|
||||
"recherche_partout": True,
|
||||
"banniere": "",
|
||||
"domaine_defaut": None,
|
||||
}
|
||||
|
||||
_DOMAINES = ("pro", "famille", "perso")
|
||||
|
||||
_verrou = threading.Lock()
|
||||
_cache: dict | None = None
|
||||
_cache_at: float = 0.0
|
||||
|
||||
|
||||
def reinitialiser_cache() -> None:
|
||||
"""Vide le cache (tests uniquement)."""
|
||||
global _cache, _cache_at
|
||||
with _verrou:
|
||||
_cache = None
|
||||
_cache_at = 0.0
|
||||
|
||||
|
||||
def _valider(data) -> dict | None:
|
||||
"""Valide la charge utile du Hub ; dict normalisé ou None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
recherche = data.get("recherche_partout")
|
||||
banniere = data.get("banniere")
|
||||
domaine = data.get("domaine_defaut")
|
||||
if not isinstance(recherche, bool):
|
||||
return None
|
||||
if not isinstance(banniere, str):
|
||||
return None
|
||||
if domaine is not None and domaine not in _DOMAINES:
|
||||
return None
|
||||
return {
|
||||
"recherche_partout": recherche,
|
||||
"banniere": banniere,
|
||||
"domaine_defaut": domaine,
|
||||
}
|
||||
|
||||
|
||||
def _consulter_hub() -> dict | None:
|
||||
"""Un appel au Hub ; retourne les réglages validés ou None."""
|
||||
from app.services import draco_urls
|
||||
|
||||
url = f"{draco_urls.hub_url().rstrip('/')}/api/reglages/effectifs"
|
||||
try:
|
||||
import httpx
|
||||
|
||||
r = httpx.get(url, headers={"Accept": "application/json"},
|
||||
timeout=DELAI)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return _valider(r.json())
|
||||
except Exception as exc: # Hub injoignable : repli, jamais d'exception
|
||||
logger.debug("Réglages du Hub indisponibles (%s): %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def reglages_effectifs() -> dict:
|
||||
"""Réglages généraux en vigueur : réponse du Hub (cache 60 s),
|
||||
sinon ancien cache, sinon les valeurs par défaut actuelles."""
|
||||
global _cache, _cache_at
|
||||
with _verrou:
|
||||
if _cache is not None and time.monotonic() - _cache_at < CACHE_TTL:
|
||||
return dict(_cache)
|
||||
# Horodatage posé avant l'appel (patron révocations) :
|
||||
# l'ancien cache reste en vigueur si l'appel échoue.
|
||||
_cache_at = time.monotonic()
|
||||
ancien = dict(_cache) if _cache is not None else None
|
||||
resultat = _consulter_hub()
|
||||
if resultat is not None:
|
||||
with _verrou:
|
||||
_cache = resultat
|
||||
_cache_at = time.monotonic()
|
||||
return dict(resultat)
|
||||
if ancien is not None:
|
||||
return ancien
|
||||
return dict(DEFAUTS)
|
||||
|
||||
|
||||
def banniere() -> str:
|
||||
"""Texte de la bannière d'annonce en vigueur ("" = aucune)."""
|
||||
return str(reglages_effectifs()["banniere"] or "")
|
||||
|
||||
|
||||
def recherche_partout() -> bool:
|
||||
"""Drapeau « Rechercher partout » en vigueur (True = actif)."""
|
||||
return bool(reglages_effectifs()["recherche_partout"])
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Rôles par application — consommation du registre du Hub (HUB-19).
|
||||
|
||||
Propagation du pilote ShaarIt (0.7.0, en production) à Flowdeck,
|
||||
sur le contrat D3(a) du plan Hub v2 : à l'ouverture de session
|
||||
draco, Flowdeck demande au Hub le niveau du porteur du cookie pour
|
||||
l'application ``flowdeck`` (``GET {hub}/api/roles/flowdeck``, en-tête
|
||||
Cookie complet de la requête relayé tel quel, ``Accept:
|
||||
application/json``, délai 2 s) et met la réponse en cache 60 s par
|
||||
``sub`` — patron du client Rust ``hub_roles.rs`` de ShaarIt et du
|
||||
cache de révocations de ``app.services.draco`` (horodatage posé
|
||||
**avant** l'appel : un échec ne déclenche pas de rafale, l'ancien
|
||||
cache reste en vigueur).
|
||||
|
||||
Réponse attendue (200) : ``{"app", "sub", "role", "explicite",
|
||||
"ttl_secondes": 60}``, rôle ∈ {``aucun``, ``utilisateur``,
|
||||
``administrateur``}.
|
||||
|
||||
**Repli obligatoire** : Hub injoignable, erreur (401/404/…), réseau,
|
||||
réponse illisible ou rôle hors ensemble → rôle **inconnu** (None),
|
||||
et l'appelant garde son comportement actuel. Le registre ne crée
|
||||
jamais un verrouillage nouveau.
|
||||
|
||||
L'URL du Hub est résolue comme la barre commune de Flowdeck
|
||||
(``app.services.draco_urls.hub_url()`` : variable explicite
|
||||
``HUB_URL`` / réglage, dérivation par l'hôte de la requête, puis par
|
||||
l'hôte sœur de l'agent, puis défaut officiel) — jamais d'URL dev
|
||||
figée.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Identifiant de Flowdeck dans le registre des applications du Hub.
|
||||
APP = "flowdeck"
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub et
|
||||
#: sur le cache de révocations draco.
|
||||
CACHE_TTL = 60 # secondes
|
||||
|
||||
ROLES_CONNUS = ("aucun", "utilisateur", "administrateur")
|
||||
|
||||
# Cache mémoire par sub : sub -> rôle connu (str) ou None (échec déjà
|
||||
# tenté dans la fenêtre — l'ancien résultat reste servi, voir
|
||||
# ``role_pour``). ``_cache_fetched_at`` est l'horodatage global du
|
||||
# dernier appel, posé avant l'appel (patron révocations).
|
||||
_cache: dict[str, str | None] = {}
|
||||
_cache_fetched_at: float = 0.0
|
||||
|
||||
|
||||
def _consulter_hub(cookie: str) -> str | None:
|
||||
"""Un appel au Hub ; retourne le rôle validé ou None (inconnu)."""
|
||||
from app.services import draco_urls
|
||||
|
||||
base = draco_urls.hub_url()
|
||||
url = f"{base}/api/roles/{APP}"
|
||||
try:
|
||||
import httpx
|
||||
|
||||
r = httpx.get(
|
||||
url,
|
||||
headers={"Cookie": cookie, "Accept": "application/json"},
|
||||
timeout=2.0,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
role = r.json().get("role")
|
||||
except Exception as exc: # réseau, JSON illisible… → repli
|
||||
logger.debug("Registre des rôles du Hub indisponible (%s): %s", url, exc)
|
||||
return None
|
||||
if role not in ROLES_CONNUS:
|
||||
return None
|
||||
return role
|
||||
|
||||
|
||||
def role_pour(cookie: str, sub: str) -> str | None:
|
||||
"""Niveau connu pour ce ``sub`` : ``aucun`` | ``utilisateur`` |
|
||||
``administrateur`` si le Hub a répondu, None si le rôle est
|
||||
inconnu (Hub injoignable/erreur) — repli = comportement actuel.
|
||||
|
||||
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
|
||||
quel au Hub ; ``sub`` est le sujet draco prouvé localement.
|
||||
Cache mémoire par ``sub``, TTL 60 s, horodatage posé avant
|
||||
l'appel : pendant la fenêtre qui suit un échec, l'ancien cache
|
||||
reste en vigueur (pas de rafale vers le Hub).
|
||||
"""
|
||||
global _cache_fetched_at
|
||||
if not sub:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
if now - _cache_fetched_at < CACHE_TTL and sub in _cache:
|
||||
return _cache[sub]
|
||||
# Horodatage posé AVANT l'appel (patron révocations du pilote).
|
||||
ancien = _cache.get(sub)
|
||||
_cache_fetched_at = now
|
||||
resultat = _consulter_hub(cookie)
|
||||
if resultat is not None:
|
||||
_cache[sub] = resultat
|
||||
return resultat
|
||||
# Échec : l'ancien cache reste en vigueur.
|
||||
logger.debug(
|
||||
"Registre des rôles du Hub indisponible — comportement actuel conservé")
|
||||
return ancien
|
||||
|
||||
|
||||
def role_draco(request) -> str | None:
|
||||
"""Rôle du porteur draco de cette requête, ou None (inconnu).
|
||||
|
||||
None aussi quand il n'y a pas de session draco prouvée : pas de
|
||||
cookie ``draco_session``, jeton invalide/expiré/révoqué, ou SSO
|
||||
draco non configuré — l'appelant garde son comportement actuel.
|
||||
Le ``sub`` vient de la vérification locale du jeton (mêmes
|
||||
sémantiques que ``user_for_draco``, sans résolution de compte ni
|
||||
provisionnement).
|
||||
"""
|
||||
from app.services import draco
|
||||
|
||||
if not draco.enabled():
|
||||
return None
|
||||
claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return None
|
||||
jti = str(claims.get("jti", ""))
|
||||
if jti and draco.is_revoked(jti):
|
||||
return None
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
# En-tête Cookie complet de la requête, relayé tel quel au Hub.
|
||||
cookie = request.headers.get("cookie", "")
|
||||
return role_pour(cookie, sub)
|
||||
|
||||
|
||||
def admin_hub_ok(request) -> bool:
|
||||
"""Vrai si les surfaces d'administration restent ouvertes.
|
||||
|
||||
Session draco + rôle connu → exiger ``administrateur``. Pas de
|
||||
session draco (session locale par mot de passe, jeton d'API
|
||||
propre à Flowdeck, OAuth externe) ou rôle inconnu (Hub
|
||||
injoignable) : comportement actuel (True).
|
||||
"""
|
||||
from app.services import draco
|
||||
|
||||
if not draco.enabled():
|
||||
return True
|
||||
claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return True
|
||||
role = role_draco(request)
|
||||
if role is None:
|
||||
return True
|
||||
return role == "administrateur"
|
||||
@@ -1,36 +1,79 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||
passe par une nouvelle version de commons.
|
||||
Snippet FIGÉ 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) : ne pas modifier la structure ici ;
|
||||
toute évolution passe par une nouvelle version de commons.
|
||||
v0.3.0 : menu compte — liens Hub Télécharger / À Propos / Aides
|
||||
et Profil devenu lien (base draco_urls().hub passée en urls.hub
|
||||
de la configuration ci-dessous) ; « Déconnexion » reste relayée.
|
||||
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
|
||||
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
|
||||
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
|
||||
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
|
||||
par défaut, noms en -dev déclarés dans le .env de l'environnement).
|
||||
5e onglet Console Spark (data-app="agent", draco_urls().agent),
|
||||
après Kanbante ; l'entrée « Console Spark » du menu de la marque
|
||||
est conservée (menu construit par draco-barre.js depuis les onglets).
|
||||
Icônes logos v0.1.9 (2026-10-29) : les 5 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).
|
||||
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000 ;
|
||||
marque ✦ → logo Spark (26 px) ; bouton « Spark » rose SUPPRIMÉ
|
||||
de la barre, remplacé par le bouton avatar flottant bas-centre
|
||||
(static/img/avatar-spark.png, data-act="spark" → panneau de l'hôte) ;
|
||||
Alertes = cloche seule, sans libellé. JS inchangé depuis v0.1.9.
|
||||
v0.1.11 (2026-10-29) : marque = étoile Spark monochrome
|
||||
(static/img/brand-spark.png, copie de ui/barre-apps/brand-spark.png
|
||||
de commons — blanche sur barre foncée, noire sur barre claire
|
||||
via le filtre du CSS vendangé) ; onglet agent et son entrée de
|
||||
menu GARDENT le logo rouge ; anneau rouge #c00000 autour du
|
||||
bouton avatar flottant (bordure du CSS vendangé). Thème de la
|
||||
barre : data-theme sur <html> (fd_theme, défaut light) — les
|
||||
règles thème de commons s'appliquent, aucune surcharge propre
|
||||
n'est nécessaire.
|
||||
v0.2.0 : 6e onglet ShaarIt (data-app="shaarit",
|
||||
draco_urls().shaarit, logo static/img/logos/shaarit.svg copie
|
||||
verbatim de commons), après Kanbante et avant l'onglet agent ;
|
||||
le CSS vendangé gagne la GARDE data-theme : ses blocs
|
||||
prefers-color-scheme sont préfixés html:not([data-theme]) —
|
||||
c'est le correctif du piège connu chez Flowdeck (barre sombre
|
||||
forcée quand le système est sombre mais que fd_theme = light) :
|
||||
changement de comportement visible, signalé en tête de la PR. #}
|
||||
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ asset_version }}">
|
||||
<header class="draco-bar" data-current="flowdeck">
|
||||
<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="" 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" data-icon="obsigate"><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" data-icon="flowdeck"><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" data-icon="tiktak"><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" data-icon="kanbante"><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" data-icon="shaarit"><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="{{ draco_urls().agent }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><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>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
@@ -49,26 +92,28 @@
|
||||
</div>
|
||||
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="draco-alertes-txt">Alertes</span>
|
||||
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
|
||||
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</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 nonce="{{ csp_nonce() }}">
|
||||
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||
le rail pilote le hamburger natif, les alertes ouvrent la cloche
|
||||
native de la page ; la recherche fédérée et Spark arrivent avec
|
||||
les phases suivantes du programme Fusion. */
|
||||
le rail pilote le hamburger natif, les alertes ouvrent le
|
||||
panneau draco-alertes.js (Fusion #69, agrégation à la demande,
|
||||
pas une fédération temps réel ; la cloche native reste le
|
||||
repli si le module ne charge pas). */
|
||||
window.DRACO_BAR_CONFIG = {
|
||||
current: "flowdeck",
|
||||
/* Barre v0.3.0 (HUB-1) : le Hub n'a pas d'onglet — sa base
|
||||
(draco_urls().hub : HUB_URL explicite, hôte de la requête,
|
||||
dérivation par l'agent, défaut officiel) alimente les
|
||||
liens du menu compte (Télécharger, À Propos, Aides,
|
||||
Profil) via urls.hub. */
|
||||
urls: { hub: "{{ draco_urls().hub }}/" },
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
counts: {},
|
||||
onAction: function (act, el) {
|
||||
@@ -82,18 +127,12 @@
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
|
||||
if (act === "alertes") {
|
||||
var b = document.querySelector(".fd-notif-bell button");
|
||||
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
||||
if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); }
|
||||
else { var b = document.querySelector(".fd-notif-bell button"); if (b) { b.click(); } }
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
||||
/* v0.1.6 : le toast placeholder "fédération" est remplacé
|
||||
par le menu par défaut du nouveau JS — retourner false le
|
||||
demande explicitement (un onAction existant garde sinon la
|
||||
main). Contenu : Console agent + applications, URL résolues
|
||||
donc dérivées -dev sur un hôte -dev. Nonce CSP du script
|
||||
inchangé. */
|
||||
if (act === "menu") { return false; }
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). Nonce CSP du script inchangé. */
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||
@@ -107,3 +146,4 @@
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
|
||||
|
||||
@@ -150,6 +150,34 @@
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
|
||||
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
|
||||
{% if not embed_mode %}{% set _banniere_hub = banniere_hub() %}{% if _banniere_hub %}
|
||||
<div class="banniere-hub" id="banniere-hub" role="status" hidden>
|
||||
<span class="banniere-hub-texte">{{ _banniere_hub }}</span>
|
||||
<button type="button" class="banniere-hub-fermer" id="banniere-hub-fermer" aria-label="Masquer la bannière" title="Masquer pour cette session du navigateur">×</button>
|
||||
</div>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||
masquage par l'utilisateur pour la session du navigateur —
|
||||
sessionStorage, valeur = texte de la bannière (une nouvelle
|
||||
annonce réapparaît donc toujours). La consultation du Hub se
|
||||
fait côté serveur (app.services.hub_reglages) ; le texte est
|
||||
échappé par l'autoescape Jinja. Sans JS, le bandeau reste
|
||||
masqué (attribut hidden). */
|
||||
(function () {
|
||||
var bandeau = document.getElementById("banniere-hub");
|
||||
if (!bandeau) return;
|
||||
var texte = {{ _banniere_hub | tojson }};
|
||||
try {
|
||||
if (sessionStorage.getItem("flowdeck_banniere_masquee") === texte) return;
|
||||
} catch (e) { /* stockage indisponible : on affiche */ }
|
||||
bandeau.hidden = false;
|
||||
document.getElementById("banniere-hub-fermer").addEventListener("click", function () {
|
||||
bandeau.hidden = true;
|
||||
try { sessionStorage.setItem("flowdeck_banniere_masquee", texte); } catch (err) { /* tant pis */ }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}{% endif %}
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title_prefix %}Rechercher partout{% endblock %}
|
||||
{% block content %}
|
||||
{# Versionnes (?v=asset_version) comme les autres feuilles/scripts de
|
||||
base.html : sans version, le service worker (static/sw.js, cache-first
|
||||
des .js/.css) resservait indefiniment les copies pre-#33 de ces deux
|
||||
fichiers aux navigateurs les ayant deja visites — la page paraissait
|
||||
donc « non corrigee » malgre les PR #33/#36. #}
|
||||
<link rel="stylesheet" href="/static/css/recherche.css?v={{ asset_version }}">
|
||||
{# Console : data-base-console rendu explicitement, comme les 3 autres
|
||||
hotes — la route Flowdeck de /recherche expose la base agent dans
|
||||
son contexte `bases` (defaut 6). recherche.js garde ses replis
|
||||
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
|
||||
<main class="rp-page" id="recherche" data-host="flowdeck"
|
||||
data-recherche-partout="{{ 'true' if recherche_partout else 'false' }}"
|
||||
data-base-obsigate="{{ bases.obsigate }}"
|
||||
data-base-flowdeck="{{ bases.flowdeck }}"
|
||||
data-base-tiktak="{{ bases.tiktak }}"
|
||||
data-base-kanbante="{{ bases.kanbante }}"
|
||||
data-base-shaarit="{{ bases.shaarit }}"
|
||||
data-base-console="{{ bases.agent }}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante, bookmarks ShaarIt — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
|
||||
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
|
||||
<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">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</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="flowdeck">
|
||||
<div class="rp-head"><h2>Flowdeck — pages</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="tiktak">
|
||||
<div class="rp-head"><h2>TikTak — tâches</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="kanbante">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/recherche.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
@@ -91,6 +91,12 @@ ENV.globals["app_base_url"] = lambda: _app_base_url()
|
||||
ENV.globals["draco_urls"] = lambda: _draco_urls()
|
||||
ENV.globals["draco_agent_url"] = lambda: _draco_urls()["agent"]
|
||||
ENV.globals["gitea_url"] = lambda: _draco_urls()["gitea"]
|
||||
# Bannière d'annonce des réglages généraux du Hub (HUB-21) : la
|
||||
# FONCTION est exposée (pas un instantané) — chaque rendu du
|
||||
# gabarit principal lit le cache serveur de 60 s de
|
||||
# app.services.hub_reglages. Hub muet → chaîne vide → aucune
|
||||
# bannière, comme aujourd'hui.
|
||||
ENV.globals["banniere_hub"] = lambda: _banniere_hub()
|
||||
|
||||
|
||||
def _app_base_url() -> str:
|
||||
@@ -105,6 +111,12 @@ def _draco_urls() -> dict:
|
||||
return draco_urls.toutes()
|
||||
|
||||
|
||||
def _banniere_hub() -> str:
|
||||
from app.services import hub_reglages
|
||||
|
||||
return hub_reglages.banniere()
|
||||
|
||||
|
||||
def _plugin_enabled(slug: str) -> bool:
|
||||
from app.services.plugins import is_enabled
|
||||
|
||||
|
||||
@@ -5342,3 +5342,21 @@ body.has-draco-bar .app-layout { height: calc(100vh - 60px); }
|
||||
background: transparent; cursor: pointer; font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; }
|
||||
|
||||
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||
bandeau rendu par base.html sous la barre commune, patron du
|
||||
pilote TikTak — accent Flowdeck, texte centré. */
|
||||
.banniere-hub {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--accent, #2f6fed); color: #fff;
|
||||
font-size: 14px; line-height: 1.35;
|
||||
}
|
||||
.banniere-hub[hidden] { display: none; }
|
||||
.banniere-hub-texte { flex: 1 1 auto; text-align: center; overflow-wrap: anywhere; }
|
||||
.banniere-hub-fermer {
|
||||
flex: 0 0 auto; border: 0; background: transparent; color: inherit;
|
||||
font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.banniere-hub-fermer:hover { background: rgba(255, 255, 255, .18); }
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||
.draco-alertes-overlay {
|
||||
position: fixed; inset: 0; z-index: 30000;
|
||||
display: none; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||
}
|
||||
.draco-alertes-box {
|
||||
width: 720px; max-width: 100%;
|
||||
background: var(--bg-primary, #1f1f1f);
|
||||
color: var(--text, #eceee9);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||
.draco-alertes-close {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||
.draco-alertes-item {
|
||||
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||
padding: 9px 12px; margin: 6px 0;
|
||||
}
|
||||
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||
.draco-alertes-title:hover { text-decoration: underline; }
|
||||
.draco-alertes-detail {
|
||||
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||
.draco-alertes-empty a { color: inherit; }
|
||||
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||
.draco-alertes-tab {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||
}
|
||||
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||
.draco-alertes-filtre input {
|
||||
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||
.draco-alertes-compte:empty { display: none; }
|
||||
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||
@@ -3,16 +3,43 @@
|
||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
|
||||
que le menu des applications) ; l'avatar est un vrai <button>.
|
||||
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
|
||||
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;
|
||||
@@ -20,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;
|
||||
@@ -34,7 +65,16 @@
|
||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { line-height: 0; }
|
||||
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
|
||||
fond transparent — noire sur barre claire (filtre), blanche sur
|
||||
barre foncée (filtre annulé par les règles thème ci-dessous). */
|
||||
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) .draco-bar .draco-brand-mark img { filter: none; }
|
||||
}
|
||||
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
@@ -45,15 +85,15 @@
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
|
||||
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
|
||||
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
|
||||
.draco-more-menu, .draco-compte-menu {
|
||||
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
|
||||
* 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);
|
||||
}
|
||||
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
|
||||
.draco-compte-menu[hidden] { display: none; }
|
||||
.draco-menu-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||
@@ -61,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;
|
||||
@@ -91,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); }
|
||||
@@ -128,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;
|
||||
@@ -166,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; }
|
||||
@@ -174,8 +235,8 @@
|
||||
@media (max-width: 900px) {
|
||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
||||
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||
avatar. Le rail, la recherche et les domaines disparaissent :
|
||||
chaque app garde ses contrôles natifs (barre
|
||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||
@@ -189,5 +250,12 @@
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||
.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. */
|
||||
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||
.rp-input {
|
||||
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||
}
|
||||
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||
.rp-section { margin-bottom: 28px; }
|
||||
.rp-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||
.rp-count {
|
||||
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||
}
|
||||
.rp-count:empty { display: none; }
|
||||
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||
.rp-state { opacity: .75; margin: 8px 0; }
|
||||
.rp-error { opacity: 1; }
|
||||
.rp-item {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid #888; margin-bottom: 20px;
|
||||
}
|
||||
.rp-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||
color: inherit; opacity: .7; white-space: nowrap;
|
||||
}
|
||||
.rp-tab:hover { opacity: 1; }
|
||||
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
.rp-tab-count {
|
||||
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||
}
|
||||
.rp-tab-count:empty { display: none; }
|
||||
.rp-section[hidden] { display: none; }
|
||||
.rp-all[hidden] { display: none; }
|
||||
|
||||
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||
.layout > .rp-page {
|
||||
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.layout > .rp-page { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||
* .content-area) et cette navigation, calée sur les variables de
|
||||
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||
* la SPA (voir le commentaire dans recherche.html). */
|
||||
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||
.rp-og-nav a {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||
.rp-og-nav a[aria-current="page"] {
|
||||
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||
}
|
||||
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||
|
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 |
@@ -64,6 +64,25 @@
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}
|
||||
|
||||
// Thème commun de la suite (HUB-18, fusion #132) : la barre
|
||||
// commune (barre 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. Le mode
|
||||
// effectif est mémorisé dans la clé locale « theme » (convergence :
|
||||
// la préférence du Hub devient le choix local courant) ; le
|
||||
// sélecteur de thème de Flowdeck (Réglages) reste le maître pour
|
||||
// la suite de la session. Repli : si le Hub ne répond pas, aucun
|
||||
// événement n'arrive et le choix local ci-dessus reste seul
|
||||
// maître, comme avant. app.js (defer) s'exécute avant la barre :
|
||||
// l'écouteur est donc toujours posé à temps pour le fetch.
|
||||
document.addEventListener('draco:theme', function(e) {
|
||||
const mode = e && e.detail && e.detail.mode;
|
||||
if (mode === 'dark' || mode === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
try { localStorage.setItem('theme', mode); } catch (err) { /* stockage indisponible */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Close context menu on click outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const menu = document.getElementById('context-menu');
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
|
||||
* (onglets + composant partagé + onglet Console Spark).
|
||||
*
|
||||
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
|
||||
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
|
||||
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
|
||||
* place, toute évolution passe par commons puis re-vendoring.
|
||||
*
|
||||
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||
* interroge les API de notifications réellement exposées par chaque
|
||||
* application, avec les identifiants du navigateur (credentials:
|
||||
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||
* ou non authentifiée affiche son état réel par section (une section
|
||||
* en échec n'empêche jamais de consulter les autres), jamais du vide
|
||||
* déguisé. En attendant le correctif CORS des allowlists (autre
|
||||
* chantier), une section inter-origines peut afficher cet état
|
||||
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
|
||||
* normal — aucun état « Injoignable » systématique n'est présenté
|
||||
* comme normal.
|
||||
*
|
||||
* Sources réelles (audit #69, 2026-10-28, inchangé) :
|
||||
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||
* - TikTak : GET {base}/api/notifications
|
||||
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||
* base + "/notifications".
|
||||
* - Kanbante : GET {base}/api/events?limit=100
|
||||
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||
* masqués), pas un journal lisible par l'utilisateur. L'onglet
|
||||
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
|
||||
* sur agent-commun main au moment de ce chantier (autre chantier
|
||||
* en cours). L'onglet Console Spark affiche donc un état vide
|
||||
* HONNÊTE (« source indisponible », lien vers la console) et ne
|
||||
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
|
||||
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
|
||||
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
|
||||
* l'autre chantier ; forme attendue : tableau, ou
|
||||
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
|
||||
* detail|message|context, date|created_at, active (booléen) ou
|
||||
* status/state (pause budget, accès requis, en attente, fermée…),
|
||||
* url|link. Les missions en pause budget / accès requis et les
|
||||
* approbations en attente y apparaîtront avec leurs états réels.
|
||||
*
|
||||
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
|
||||
* « Console Spark » ajouté par le chantier 5 porte data-app
|
||||
* "agent", donc la base agent suit le même ordre) ;
|
||||
* 4. défaut officiel (production).
|
||||
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
|
||||
* incluse).
|
||||
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||
* que sur un hôte -dev, exactement comme la barre.
|
||||
*
|
||||
* Présentation (identique partout, imposée par ce seul fichier) :
|
||||
* champs par alerte = application, titre, date, état, lien profond
|
||||
* (environnement -dev respecté via la résolution ci-dessus) ;
|
||||
* ordre = date décroissante dans chaque onglet (sans date en
|
||||
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
|
||||
* si absente ou illisible ; libellés d'état affichés = « Active »,
|
||||
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
|
||||
* ou l'état réel console quand la source existe. Le type brut
|
||||
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
|
||||
* libellé d'état en anglais.
|
||||
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
|
||||
* par application ne montrent QUE les alertes actives ; Historique
|
||||
* ne montre QUE les alertes fermées (lues / événements Kanbante /
|
||||
* console fermées). Le filtre texte (titre + contexte) s'applique
|
||||
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
|
||||
* actifs et historique. L'onglet courant est dans l'URL
|
||||
* (#alertes=<onglet>, partageable, retour arrière via pushState).
|
||||
*
|
||||
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||
* pour la pastille (repli : mise à jour directe du badge).
|
||||
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||
* avec une valeur venue du serveur.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_URLS = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
agent: "https://agent.dracodev.net/"
|
||||
};
|
||||
var DEV_URLS = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
|
||||
};
|
||||
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
|
||||
var TAB_LABELS = {
|
||||
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante",
|
||||
console: "Console Spark", historique: "Historique"
|
||||
};
|
||||
var EVENT_LABELS = {
|
||||
created: "créée", moved: "déplacée", closed: "fermée",
|
||||
reopened: "rouverte", commented: "commentée"
|
||||
};
|
||||
var CONSOLE_ACTIVE_STATUSES = [
|
||||
"active", "pending", "pause", "pause_budget", "acces_requis",
|
||||
"en_attente", "en attente", "requise", "awaiting"
|
||||
];
|
||||
var CONSOLE_CLOSED_STATUSES = [
|
||||
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
|
||||
];
|
||||
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
function cleanUrl(u) {
|
||||
if (!u || typeof u !== "string") return null;
|
||||
if (u.indexOf("{{") !== -1) return null;
|
||||
if (!u.replace(/\s/g, "")) return null;
|
||||
return u;
|
||||
}
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||
}
|
||||
function joinUrl(base, path) {
|
||||
var b = withSlash(base);
|
||||
if (!path) return b;
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return b + String(path).replace(/^\//, "");
|
||||
}
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||
? String(s).replace(" ", "T") + "Z" : s);
|
||||
return isNaN(t.getTime()) ? null : t;
|
||||
}
|
||||
function fmtDate(s) {
|
||||
var t = parseDate(s);
|
||||
if (!t) return "";
|
||||
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||
}
|
||||
function capitalize(s) {
|
||||
s = String(s || "");
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
|
||||
function resolveUrls(env) {
|
||||
env = env || {};
|
||||
var hostname = env.hostname;
|
||||
if (hostname === undefined) {
|
||||
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||
}
|
||||
var dev = isDevHost(hostname);
|
||||
var urls = {};
|
||||
ALL_APPS.forEach(function (key) {
|
||||
var app = key === "console" ? "agent" : key;
|
||||
var out = key === "console" ? "agent" : key;
|
||||
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|
||||
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
if (!u) {
|
||||
var href = null;
|
||||
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
|
||||
else if (typeof document !== "undefined" && document.querySelector) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
href = tab ? tab.getAttribute("href") : null;
|
||||
}
|
||||
u = cleanUrl(href);
|
||||
}
|
||||
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
|
||||
if (!u) {
|
||||
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||
try {
|
||||
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[out] = withSlash(u);
|
||||
if (key === "console") urls.console = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
function browserEnv() {
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
var bar = global.DRACO_BAR || {};
|
||||
var tabs = {};
|
||||
try {
|
||||
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||
}
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
return {
|
||||
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
|
||||
consoleUrl: null
|
||||
};
|
||||
}
|
||||
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
|
||||
* n'est devinée : sans configuration, la source est indisponible
|
||||
* (état vide honnête, voir renderCurrent). */
|
||||
function consoleSourceUrl(env) {
|
||||
env = env || {};
|
||||
if (env.consoleUrl) return env.consoleUrl;
|
||||
try {
|
||||
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
|
||||
} catch (e) { /* rien */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFlowdeck(base, payload) {
|
||||
var list = (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.message || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeTiktak(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.body || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.kind || "",
|
||||
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeKanbante(base, payload) {
|
||||
var list = (payload && payload.events) || [];
|
||||
return list.map(function (e) {
|
||||
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||
return {
|
||||
app: "kanbante", id: e.id,
|
||||
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||
detail: detail, date: e.created_at || "",
|
||||
active: false, state: label, kind: e.event_type || "",
|
||||
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeConsole(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload
|
||||
: (payload && (payload.alerts || payload.items)) || [];
|
||||
return list.map(function (n) {
|
||||
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
|
||||
var stateRaw = String(n.state || status || "");
|
||||
var active;
|
||||
if (typeof n.active === "boolean") active = n.active;
|
||||
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
|
||||
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
|
||||
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
|
||||
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
|
||||
else active = true; /* source réelle, statut inconnu : on l'expose en active */
|
||||
return {
|
||||
app: "console", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
|
||||
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
|
||||
kind: n.kind || n.type || "",
|
||||
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
|
||||
* normalize* sont inchangées, pour compatibilité des tests #69). */
|
||||
function displayState(a) {
|
||||
if (a.active) return "Active";
|
||||
if (a.app === "kanbante") return capitalize(a.state);
|
||||
if (a.app === "console") return capitalize(a.state);
|
||||
return "Lue";
|
||||
}
|
||||
function metaLine(a) {
|
||||
var date = fmtDate(a.date) || "sans date";
|
||||
/* Kanbante : le type brut (« moved ») dupliquait le libellé
|
||||
* d'état ; il n'est plus affiché. Les autres apps gardent leur
|
||||
* type quand il apporte une information. */
|
||||
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
|
||||
var kind = a.kind ? " · " + a.kind : "";
|
||||
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier).
|
||||
* Dans un onglet, le périmètre est déjà homogène (que des actives
|
||||
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
|
||||
function sortAlerts(list) {
|
||||
return list.slice().sort(function (a, b) {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
var da = parseDate(a.date), db = parseDate(b.date);
|
||||
if (da && db) return db - da;
|
||||
if (da) return -1;
|
||||
if (db) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
function countActive(list) {
|
||||
var n = 0;
|
||||
list.forEach(function (a) { if (a.active) n++; });
|
||||
return n;
|
||||
}
|
||||
function allItems(results) {
|
||||
var all = [];
|
||||
ALL_APPS.forEach(function (app) {
|
||||
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
return all;
|
||||
}
|
||||
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
|
||||
* Tout et onglets par application = actives seulement ; Historique =
|
||||
* fermées seulement. Les erreurs de section sont remontées dans
|
||||
* chaque onglet concerné (jamais silencieusement avalées). */
|
||||
function scopeAlerts(all, tab, results) {
|
||||
results = results || {};
|
||||
var items = [], errors = [];
|
||||
function collect(app, pred) {
|
||||
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
|
||||
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
|
||||
}
|
||||
if (tab === "historique") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
|
||||
} else if (tab === "tout") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
} else if (ALL_APPS.indexOf(tab) !== -1) {
|
||||
collect(tab, function (a) { return a.active; });
|
||||
} else {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
}
|
||||
return {
|
||||
items: sortAlerts(items), errors: errors,
|
||||
consoleMissing: !!(results.console && results.console.sourceMissing)
|
||||
};
|
||||
}
|
||||
function countByTab(all, results) {
|
||||
var counts = {};
|
||||
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
|
||||
return counts;
|
||||
}
|
||||
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
|
||||
* contexte, insensible à la casse, sans jamais mélanger actifs et
|
||||
* historique (le périmètre est déjà fixé par scopeAlerts). */
|
||||
function filterAlerts(list, q) {
|
||||
q = String(q || "").trim().toLowerCase();
|
||||
if (!q) return list.slice();
|
||||
return list.filter(function (a) {
|
||||
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|
||||
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
}
|
||||
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
|
||||
* "#alertes" -> "tout", autre -> null. */
|
||||
function parseAlertTab(hash) {
|
||||
if (!hash) return null;
|
||||
var h = String(hash).replace(/^#/, "");
|
||||
if (h === "alertes") return "tout";
|
||||
var m = /^alertes=([a-z]+)$/.exec(h);
|
||||
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
|
||||
return null;
|
||||
}
|
||||
function hashForTab(tab) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
return "#alertes=" + tab;
|
||||
}
|
||||
|
||||
/* ---- Rendu (navigateur seulement) ---- */
|
||||
var panel = null, listEl = null, tabsEl = null, filterEl = null,
|
||||
countEl = null, badgeTimer = null;
|
||||
var currentTab = "tout", currentData = null, currentFilter = "";
|
||||
var panelPushed = false, closingViaBack = false, suppressHash = false;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var d = document.createElement(tag);
|
||||
if (cls) d.className = cls;
|
||||
if (text !== undefined && text !== null) d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
function ensurePanel() {
|
||||
if (panel) return panel;
|
||||
var ov = el("div", "draco-alertes-overlay");
|
||||
ov.setAttribute("role", "dialog");
|
||||
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||
var box = el("div", "draco-alertes-box");
|
||||
var head = el("div", "draco-alertes-head");
|
||||
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||
var close = el("button", "draco-alertes-close", "✕");
|
||||
close.setAttribute("aria-label", "Fermer");
|
||||
close.addEventListener("click", function () { closePanel(); });
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
box.appendChild(el("p", "draco-alertes-note",
|
||||
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||
"les alertes sont relues dans chaque application à l'ouverture. " +
|
||||
"Une section en échec n'empêche pas de consulter les autres."));
|
||||
tabsEl = el("div", "draco-alertes-tabs");
|
||||
tabsEl.setAttribute("role", "tablist");
|
||||
tabsEl.setAttribute("aria-label", "Alertes par application");
|
||||
tabsEl.addEventListener("keydown", onTabsKeydown);
|
||||
box.appendChild(tabsEl);
|
||||
var filtre = el("div", "draco-alertes-filtre");
|
||||
var lbl = el("label", "", "Filtrer");
|
||||
lbl.setAttribute("for", "draco-alertes-filtre-input");
|
||||
filtre.appendChild(lbl);
|
||||
filterEl = el("input", "");
|
||||
filterEl.type = "search";
|
||||
filterEl.id = "draco-alertes-filtre-input";
|
||||
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
|
||||
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
|
||||
filterEl.addEventListener("input", function () {
|
||||
currentFilter = filterEl.value;
|
||||
renderCurrent();
|
||||
});
|
||||
filtre.appendChild(filterEl);
|
||||
box.appendChild(filtre);
|
||||
countEl = el("p", "draco-alertes-compte", "");
|
||||
box.appendChild(countEl);
|
||||
listEl = el("div", "draco-alertes-list");
|
||||
listEl.setAttribute("role", "tabpanel");
|
||||
box.appendChild(listEl);
|
||||
ov.appendChild(box);
|
||||
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
panel = ov;
|
||||
return ov;
|
||||
}
|
||||
function closePanel() {
|
||||
if (!panel) return;
|
||||
panel.style.display = "none";
|
||||
if (panelPushed) {
|
||||
panelPushed = false;
|
||||
closingViaBack = true;
|
||||
try { history.back(); } catch (e) { closingViaBack = false; }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var h = parseAlertTab(window.location.hash);
|
||||
if (h) {
|
||||
suppressHash = true;
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
suppressHash = false;
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function onHashChange() {
|
||||
if (suppressHash) return;
|
||||
if (closingViaBack) { closingViaBack = false; return; }
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) {
|
||||
if (!panel || panel.style.display === "none") openPanel(tab);
|
||||
else setTab(tab, false);
|
||||
} else if (panel && panel.style.display !== "none") {
|
||||
panelPushed = false;
|
||||
panel.style.display = "none";
|
||||
}
|
||||
}
|
||||
function onTabsKeydown(ev) {
|
||||
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
|
||||
if (keys.indexOf(ev.key) === -1) return;
|
||||
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
|
||||
var idx = TABS.indexOf(currentTab);
|
||||
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
|
||||
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
|
||||
else if (ev.key === "Home") idx = 0;
|
||||
else if (ev.key === "End") idx = TABS.length - 1;
|
||||
ev.preventDefault();
|
||||
setTab(TABS[idx], true);
|
||||
if (btns[idx] && btns[idx].focus) btns[idx].focus();
|
||||
}
|
||||
function renderTabs() {
|
||||
if (!tabsEl || !currentData) return;
|
||||
tabsEl.textContent = "";
|
||||
var all = allItems(currentData.results);
|
||||
var counts = countByTab(all, currentData.results);
|
||||
TABS.forEach(function (tab) {
|
||||
var b = el("button", "draco-alertes-tab",
|
||||
TAB_LABELS[tab] + " (" + counts[tab] + ")");
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "tab");
|
||||
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
|
||||
b.tabIndex = tab === currentTab ? 0 : -1;
|
||||
b.addEventListener("click", function () { setTab(tab, true); });
|
||||
tabsEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
function renderRow(a) {
|
||||
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||
var main = el("div", "draco-alertes-main");
|
||||
var link = el("a", "draco-alertes-title", a.title);
|
||||
link.href = a.url;
|
||||
main.appendChild(link);
|
||||
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderErrorRow(app, msg) {
|
||||
var row = el("div", "draco-alertes-item is-error");
|
||||
var main = el("div", "draco-alertes-main");
|
||||
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
|
||||
main.appendChild(el("div", "draco-alertes-detail", msg));
|
||||
main.appendChild(el("div", "draco-alertes-meta",
|
||||
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderCurrent() {
|
||||
if (!listEl || !currentData) return;
|
||||
renderTabs();
|
||||
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
|
||||
var filtered = filterAlerts(scope.items, currentFilter);
|
||||
var q = String(currentFilter || "").trim();
|
||||
if (countEl) {
|
||||
countEl.textContent = q
|
||||
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
|
||||
: "";
|
||||
}
|
||||
listEl.textContent = "";
|
||||
/* Onglet Console sans source : état vide honnête, jamais d'alertes
|
||||
* inventées, avec le lien profond vers la console. */
|
||||
if (currentTab === "console" && scope.consoleMissing) {
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", "Console Spark (0)"));
|
||||
sec.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte console — source indisponible : l'endpoint " +
|
||||
"d'alertes console (agent-commun, autre chantier) n'est pas " +
|
||||
"encore publié. Les missions en pause budget / accès requis " +
|
||||
"et les approbations en attente apparaîtront ici, avec leurs " +
|
||||
"états réels, dès sa livraison."));
|
||||
var link = el("div", "draco-alertes-empty");
|
||||
var a = el("a", "", "Ouvrir la Console Spark");
|
||||
a.href = currentData.urls.agent || currentData.urls.console || "#";
|
||||
link.appendChild(a);
|
||||
sec.appendChild(link);
|
||||
listEl.appendChild(sec);
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
return;
|
||||
}
|
||||
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
|
||||
var secOg = el("section", "draco-alertes-section");
|
||||
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||
secOg.appendChild(el("p", "draco-alertes-empty",
|
||||
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||
var linkOg = el("div", "draco-alertes-empty");
|
||||
var aOg = el("a", "", "Ouvrir ObsiGate");
|
||||
aOg.href = currentData.urls.obsigate;
|
||||
linkOg.appendChild(aOg);
|
||||
secOg.appendChild(linkOg);
|
||||
listEl.appendChild(secOg);
|
||||
return;
|
||||
}
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
if (!scope.items.length && !scope.errors.length) {
|
||||
var empty = currentTab === "historique"
|
||||
? "Aucune alerte fermée dans les applications joignables."
|
||||
: currentTab === "tout"
|
||||
? "Aucune alerte active dans les applications joignables."
|
||||
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
|
||||
if (currentTab === "console") empty = "Aucune alerte console.";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", empty));
|
||||
return;
|
||||
}
|
||||
if (q && !filtered.length) {
|
||||
listEl.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte dans cet onglet pour ce filtre."));
|
||||
return;
|
||||
}
|
||||
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
|
||||
}
|
||||
function setTab(tab, push) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
currentTab = tab;
|
||||
renderCurrent();
|
||||
if (push) {
|
||||
try {
|
||||
history.pushState(null, "", hashForTab(tab));
|
||||
panelPushed = true;
|
||||
} catch (e) { /* URL partageable : repli silencieux */ }
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson(url, timeoutMs) {
|
||||
var ctrl = null, timer = null;
|
||||
try {
|
||||
if (typeof AbortController !== "undefined") {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||
}
|
||||
} catch (e) { ctrl = null; }
|
||||
var opts = { credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
return fetch(url, opts).then(function (r) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!r.ok) {
|
||||
var err = new Error("HTTP " + r.status);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return r.json();
|
||||
}).catch(function (e) {
|
||||
if (timer) clearTimeout(timer);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
function loadAll() {
|
||||
var urls = resolveUrls(browserEnv());
|
||||
var consoleUrl = consoleSourceUrl(browserEnv());
|
||||
var jobs = {
|
||||
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||
};
|
||||
if (consoleUrl) {
|
||||
var agentBase = urls.agent || urls.console;
|
||||
jobs.console = fetchJson(consoleUrl)
|
||||
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
|
||||
}
|
||||
var keys = Object.keys(jobs);
|
||||
return Promise.all(keys.map(function (k) {
|
||||
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||
var msg = (e && e.status === 401)
|
||||
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||
return [k, { items: [], error: msg }];
|
||||
});
|
||||
})).then(function (pairs) {
|
||||
var results = { obsigate: { items: [] } };
|
||||
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
|
||||
return { urls: urls, results: results };
|
||||
});
|
||||
}
|
||||
function setBadge(n) {
|
||||
try {
|
||||
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||
global.DRACO_BAR.setCounts({ alertes: n });
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
try {
|
||||
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||
b.textContent = n;
|
||||
b.hidden = !(n > 0);
|
||||
});
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function refreshBadge() {
|
||||
return loadAll().then(function (r) {
|
||||
var n = countActive(allItems(r.results));
|
||||
setBadge(n);
|
||||
return n;
|
||||
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||
}
|
||||
function openPanel(initialTab) {
|
||||
ensurePanel();
|
||||
panel.style.display = "flex";
|
||||
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
|
||||
currentTab = tab;
|
||||
currentFilter = "";
|
||||
if (filterEl) filterEl.value = "";
|
||||
listEl.textContent = "";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||
if (tabsEl) tabsEl.textContent = "";
|
||||
loadAll().then(function (r) {
|
||||
currentData = r;
|
||||
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
|
||||
* l'URL pour être partageable ; ouverture par lien partagé : le
|
||||
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
|
||||
var fromHash = parseAlertTab(window.location.hash);
|
||||
if (!fromHash) {
|
||||
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
|
||||
}
|
||||
renderCurrent();
|
||||
setBadge(countActive(allItems(r.results)));
|
||||
}).catch(function () {
|
||||
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
|
||||
renderCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
var api = {
|
||||
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||
_test: {
|
||||
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
|
||||
sortAlerts: sortAlerts, countActive: countActive,
|
||||
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
|
||||
countByTab: countByTab, parseAlertTab: parseAlertTab,
|
||||
hashForTab: hashForTab, displayState: displayState,
|
||||
metaLine: metaLine, fmtDate: fmtDate,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
|
||||
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
|
||||
}
|
||||
};
|
||||
global.DRACO_ALERTES = api;
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||
|
||||
/* Pastille au chargement (après les scripts defer de la barre) ;
|
||||
* un lien partagé #alertes=<onglet> ouvre directement le panneau
|
||||
* sur cet onglet. */
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
badgeTimer = setTimeout(refreshBadge, 300);
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) setTimeout(function () { openPanel(tab); }, 350);
|
||||
});
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -4,7 +4,44 @@
|
||||
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
|
||||
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
|
||||
* correctif fusion #70 (SANS bump de version, publication séparée) :
|
||||
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
|
||||
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
|
||||
* « Menu des applications », « Focus » et « Écouteurs globaux ») ;
|
||||
* v0.2.0 : 5e application ShaarIt — clé urls.shaarit, défaut officiel
|
||||
* https://shaarit.dracodev.net/, dérivation -dev shaarit-dev (même
|
||||
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
|
||||
* (data-app="shaarit", après Kanbante, avant agent) est repris
|
||||
* automatiquement par la construction du menu de la marque.
|
||||
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
|
||||
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
|
||||
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
|
||||
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
|
||||
* sœurs. Le menu compte gagne trois vrais liens (patron
|
||||
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
|
||||
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
|
||||
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
|
||||
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
|
||||
* celui du Hub). « Déconnexion » reste l'action relayée
|
||||
* (data-compte-act="deconnexion" / onAction), inchangée.
|
||||
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
|
||||
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
|
||||
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
|
||||
* sans étape « href rendu » (voir « URL » ci-dessous).
|
||||
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
|
||||
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
|
||||
* reçoit un glyphe SVG inline dans le style des icônes du rail de
|
||||
* la console (contour, trait fin régulier, currentColor, aucun
|
||||
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
|
||||
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
|
||||
* comportement : liens résolus et Déconnexion relayée inchangés.
|
||||
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
|
||||
* la barre apprend la préférence de thème du profil du Hub
|
||||
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
|
||||
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
|
||||
* local court et repli intégral sur le comportement actuel de
|
||||
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
|
||||
*
|
||||
* 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
|
||||
@@ -22,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
|
||||
@@ -47,29 +86,85 @@
|
||||
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
||||
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
|
||||
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
|
||||
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
|
||||
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
|
||||
* Spark (panneau-agent), qui suit le même contrat d'injection.
|
||||
* La clé spark ne correspond à aucun onglet de la barre : elle est
|
||||
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
|
||||
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
|
||||
* elle, a un onglet depuis le chantier 5 (ordre, v0.2.0 : ObsiGate,
|
||||
* Flowdeck, TikTak, Kanbante, ShaarIt, Console Spark) : elle suit
|
||||
* donc l'étape « href
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls. Même résolution pour shaarit (v0.2.0) :
|
||||
* explicite > dérivation -dev shaarit-dev > href rendu
|
||||
* ({{URL_SHAARIT}}) > défaut officiel.
|
||||
* Cas hub (v0.3.0) : PAS d'onglet, donc pas d'étape « href rendu » —
|
||||
* explicite (DRACO_BAR_CONFIG.urls.hub, alimenté par le jeton
|
||||
* {{URL_HUB}} remplacé côté serveur par l'hôte) > dérivation -dev
|
||||
* hub-dev sur hôte -dev > défaut officiel
|
||||
* https://hub.dracodev.net/. La base résolue sert les quatre liens
|
||||
* du menu compte (telecharger, a-propos, aide, profil).
|
||||
* 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…).
|
||||
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||
* ouvre le menu des applications, construit depuis les onglets ;
|
||||
* ouvre le menu des applications, construit depuis les onglets, plus
|
||||
* (depuis le correctif #70) une entrée « Console Spark » vers la
|
||||
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
|
||||
* c'est le moyen de changer d'app en mobile, où les onglets sont
|
||||
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
|
||||
* repliés (v0.1.2). Menu compte : voir ci-dessous.
|
||||
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
|
||||
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
|
||||
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
|
||||
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
|
||||
* retirées des gabarits hôtes — le menu natif d'ObsiGate
|
||||
* (#header-menu-btn) lui-même n'est pas touché.
|
||||
*
|
||||
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
|
||||
* connu à gérer l'action "menu" via onAction (il déclenche son propre
|
||||
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
|
||||
* pas et le bouton restait mort (relais vers un onAction absent, puis
|
||||
* événement draco:bar sans écouteur). Comportement désormais :
|
||||
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
|
||||
* la console agent (URL agent résolue, donc dérivée -dev sur un
|
||||
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
|
||||
* par clic extérieur, Échap ou choix d'un lien ;
|
||||
* - avec onAction : l'hôte garde la main (comportement historique,
|
||||
* zéro régression pour ObsiGate) ; il peut demander explicitement
|
||||
* le menu par défaut en retournant false pour l'action "menu".
|
||||
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
|
||||
* son menu par défaut (console agent + applications) sont supprimés
|
||||
* du fragment, du JS et du CSS. La console agent reste joignable par
|
||||
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
|
||||
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
|
||||
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
|
||||
* onglets présents dans la barre, puis une entrée « Console Spark »
|
||||
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
||||
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
|
||||
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
|
||||
* 4) défaut officiel prod https://agent.dracodev.net/.
|
||||
* AUCUN hôte spark-dev n'est inventé :
|
||||
* la clé spark, elle, suit toujours son propre contrat (explicite >
|
||||
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
||||
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
||||
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
|
||||
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
|
||||
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
|
||||
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
|
||||
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
|
||||
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
|
||||
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
|
||||
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
|
||||
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
|
||||
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
|
||||
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
|
||||
* addGlobalListener), PAS via document.addEventListener. Cause
|
||||
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
|
||||
* remplace document/window.addEventListener par un registre
|
||||
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
|
||||
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
|
||||
* compris ceux de draco-barre.js (script defer, enregistré après le
|
||||
* tcInit() initial). La barre survit à la navigation (hors .layout
|
||||
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
|
||||
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
|
||||
* la marque, lien href="/", est interceptée par l'intercepteur SPA
|
||||
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
|
||||
* une navigation interne au lieu de basculer le menu. Les écouteurs
|
||||
* posés sur le prototype ne figurent pas dans le registre et ne sont
|
||||
* donc jamais supprimés par le teardown ; en complément, le gabarit
|
||||
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
|
||||
* la marque ne déclenche plus de navigation douce. Les autres hôtes
|
||||
* (SSR, rechargement complet) ne sont pas concernés par le teardown
|
||||
* mais bénéficient du même code ; repli : si EventTarget.prototype
|
||||
* est indisponible (mini-DOM de tests), document.addEventListener
|
||||
* classique est utilisé.
|
||||
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
|
||||
* un « B » codé en dur dans le fragment, est un vrai <button>
|
||||
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
|
||||
@@ -81,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";
|
||||
@@ -93,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
|
||||
@@ -106,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
|
||||
@@ -167,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. */
|
||||
@@ -223,7 +455,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
|
||||
/* ── Menus déroulants (apps, compte) ───────────────────────────────
|
||||
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||
@@ -231,9 +463,11 @@
|
||||
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||
var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
|
||||
var avatarBtn = document.querySelector(".draco-avatar");
|
||||
var appsMenu = null, moreMenu = null, compteMenu = null;
|
||||
var appsMenu = null, compteMenu = null;
|
||||
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
|
||||
* lui rend le focus à la fermeture (correctif #70). */
|
||||
var lastOpener = null;
|
||||
|
||||
function setExpanded(btn, open) {
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
@@ -243,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;
|
||||
@@ -250,10 +506,13 @@
|
||||
menu.hidden = !open;
|
||||
setExpanded(btn, open);
|
||||
}
|
||||
function closeAllPopups() {
|
||||
function closeAllPopups(returnFocus) {
|
||||
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
|
||||
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
|
||||
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||
/* Échap seulement : le clic extérieur ne déplace pas le focus
|
||||
* (voir l'en-tête, « Focus »). */
|
||||
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
|
||||
}
|
||||
function tabLabel(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
@@ -265,28 +524,6 @@
|
||||
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||
return petit ? petit.textContent : "";
|
||||
}
|
||||
function menuLink(app, nom, role) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item draco-menu-item";
|
||||
a.href = resolvedUrls[app] || "#";
|
||||
a.dataset.app = app;
|
||||
a.setAttribute("role", "menuitem");
|
||||
var txt = document.createElement("span");
|
||||
txt.className = "draco-apps-item-txt";
|
||||
var n = document.createElement("span");
|
||||
n.className = "draco-apps-item-nom";
|
||||
n.textContent = nom;
|
||||
txt.appendChild(n);
|
||||
if (role) {
|
||||
var r = document.createElement("span");
|
||||
r.className = "draco-apps-item-role";
|
||||
r.textContent = role;
|
||||
txt.appendChild(r);
|
||||
}
|
||||
a.appendChild(txt);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* Menu des applications (v0.1.3) — construit depuis les onglets
|
||||
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
|
||||
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
|
||||
@@ -300,6 +537,11 @@
|
||||
appsMenu.setAttribute("role", "menu");
|
||||
appsMenu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
|
||||
* repris ici — l'entrée dédiée ci-dessous le représente déjà
|
||||
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
|
||||
* listerait « Console Spark » deux fois. */
|
||||
if (t.dataset.app === "agent") return;
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||
@@ -326,6 +568,49 @@
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
/* Entrée « Console Spark » (#70) : après les onglets d'apps
|
||||
* (ObsiGate…ShaarIt, v0.2.0), vers la console
|
||||
* de l'agent commun (URL agent résolue — explicite > -dev >
|
||||
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
|
||||
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
|
||||
* autres items ; son icône CLONE celle de l'onglet agent
|
||||
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
|
||||
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
|
||||
* autres éléments de la barre n'est pas changé ici. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
appsMenu.appendChild(sep);
|
||||
var sparkItem = document.createElement("a");
|
||||
sparkItem.className = "draco-apps-item";
|
||||
sparkItem.href = resolvedUrls.agent || "#";
|
||||
sparkItem.dataset.app = "agent";
|
||||
sparkItem.setAttribute("role", "menuitem");
|
||||
var sparkIco;
|
||||
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
|
||||
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
|
||||
if (agentIco) {
|
||||
sparkIco = agentIco.cloneNode(true);
|
||||
} else {
|
||||
sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#d21f2c");
|
||||
sparkIco.setAttribute("data-icon", "agent");
|
||||
sparkIco.textContent = "✦";
|
||||
}
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
var sparkNom = document.createElement("span");
|
||||
sparkNom.className = "draco-apps-item-nom";
|
||||
sparkNom.textContent = "Console Spark";
|
||||
sparkTxt.appendChild(sparkNom);
|
||||
var sparkRole = document.createElement("span");
|
||||
sparkRole.className = "draco-apps-item-role";
|
||||
sparkRole.textContent = "Agent commun";
|
||||
sparkTxt.appendChild(sparkRole);
|
||||
sparkItem.appendChild(sparkTxt);
|
||||
appsMenu.appendChild(sparkItem);
|
||||
bar.appendChild(appsMenu);
|
||||
}
|
||||
function toggleAppsMenu(force) {
|
||||
@@ -333,40 +618,83 @@
|
||||
if (!appsMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||
togglePopup(appsMenu, brand, force);
|
||||
if (!appsMenu.hidden) lastOpener = brand;
|
||||
}
|
||||
|
||||
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
|
||||
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
|
||||
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
|
||||
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
|
||||
function buildMoreMenu() {
|
||||
if (!bar || !moreBtn || moreMenu) return;
|
||||
moreMenu = document.createElement("div");
|
||||
moreMenu.className = "draco-more-menu";
|
||||
moreMenu.hidden = true;
|
||||
moreMenu.setAttribute("role", "menu");
|
||||
moreMenu.setAttribute("aria-label", "Plus d'actions");
|
||||
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
moreMenu.appendChild(sep);
|
||||
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
|
||||
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
|
||||
/* 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);
|
||||
});
|
||||
bar.appendChild(moreMenu);
|
||||
}
|
||||
function toggleMoreMenu(force) {
|
||||
buildMoreMenu();
|
||||
if (!moreMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(moreMenu);
|
||||
togglePopup(moreMenu, moreBtn, force);
|
||||
span.appendChild(svg);
|
||||
return span;
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
/* 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");
|
||||
@@ -377,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() {
|
||||
@@ -393,12 +734,14 @@
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
refreshCompteHrefs();
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
buildCompteMenu();
|
||||
if (!compteMenu) return;
|
||||
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||
togglePopup(compteMenu, avatarBtn, force);
|
||||
if (!compteMenu.hidden) lastOpener = avatarBtn;
|
||||
}
|
||||
|
||||
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||
@@ -408,7 +751,21 @@
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
/* Écouteurs globaux posés sur le prototype EventTarget quand il
|
||||
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
|
||||
* voir l'en-tête) ; sinon document.addEventListener classique. */
|
||||
function addGlobalListener(type, fn) {
|
||||
try {
|
||||
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
|
||||
EventTarget.prototype.addEventListener) {
|
||||
EventTarget.prototype.addEventListener.call(document, type, fn, false);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
document.addEventListener(type, fn);
|
||||
}
|
||||
|
||||
addGlobalListener("click", function (ev) {
|
||||
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||
if (compteItem) {
|
||||
@@ -419,38 +776,21 @@
|
||||
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
if (moreMenu) toggleMoreMenu(false);
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
toggleAppsMenu();
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "menu") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
/* Hôte prioritaire : il garde la main, sauf s'il demande
|
||||
* explicitement le menu par défaut en retournant false.
|
||||
* Sans onAction, le menu par défaut s'ouvre (il ne restait
|
||||
* auparavant qu'un événement draco:bar sans écouteur). */
|
||||
if (typeof cfg.onAction === "function") {
|
||||
var handled = cfg.onAction("menu", el, ev);
|
||||
if (handled === false) toggleMoreMenu();
|
||||
} else {
|
||||
toggleMoreMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "compte") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
if (moreMenu) toggleMoreMenu(false);
|
||||
toggleCompteMenu();
|
||||
return;
|
||||
}
|
||||
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||
* sur son déclencheur déjà traité ci-dessus). */
|
||||
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups();
|
||||
ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups(false);
|
||||
}
|
||||
if (!el) return;
|
||||
var act = el.dataset.act;
|
||||
@@ -458,8 +798,23 @@
|
||||
relay(act, el, ev);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") closeAllPopups();
|
||||
addGlobalListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") { closeAllPopups(true); return; }
|
||||
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
|
||||
* ouverture explicite + focus sur le premier item du menu (#70).
|
||||
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
|
||||
* ci-dessus (pas de double bascule ici). */
|
||||
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
|
||||
var brandEl = ev.target.closest('[data-act="apps"]');
|
||||
if (brandEl) {
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
toggleAppsMenu();
|
||||
if (appsMenu && !appsMenu.hidden) {
|
||||
var first = appsMenu.querySelector(".draco-apps-item");
|
||||
if (first && typeof first.focus === "function") first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||
@@ -471,20 +826,24 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
applyUrls(resolvedUrls);
|
||||
buildAppsMenu();
|
||||
buildMoreMenu();
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
initThemeCommun();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
|
||||
const CACHE_NAME = 'flowdeck-v9'; // v9 : purge des caches pre-#33/#36 —
|
||||
// recherche.js/recherche.css etaient servis cache-first SANS version
|
||||
// (?v= absent dans recherche.html) : les navigateurs ayant visite
|
||||
// Flowdeck avant les onglets gardaient les copies pre-#33 (JS sans
|
||||
// onglets/compteurs/console, CSS sans .rp-tabs) malgre les deplois
|
||||
// 7.84.3/7.84.4. Le bump purge ces caches a l'activation, et les
|
||||
// liens versionnes (?v=asset_version) font des futurs changements
|
||||
// de nouvelles cles de cache.
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Tests unitaires (node:test) du module partagé draco-alertes.js.
|
||||
// Exécutés dans la sandbox : node --test draco-alertes.test.js
|
||||
// Copie vendangée byte-identique de Projets/dracodev-commons
|
||||
// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
|
||||
// Hôte : Flowdeck
|
||||
// Aucune donnée inventée : les payloads reprennent la forme exacte
|
||||
// des API réelles (Flowdeck /api/notifications, TikTak
|
||||
// /api/notifications, Kanbante /api/events). Le payload console
|
||||
// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
|
||||
// endpoint console n'est publié sur agent-commun main au moment
|
||||
// du chantier.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const t = require("../static/js/draco-alertes.js");
|
||||
|
||||
function sampleResults() {
|
||||
return {
|
||||
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
}) },
|
||||
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]) },
|
||||
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
}) },
|
||||
obsigate: { items: [] },
|
||||
console: { items: [], sourceMissing: true },
|
||||
};
|
||||
}
|
||||
function allOf(results) {
|
||||
const all = [];
|
||||
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
|
||||
(results[app] && results[app].items || []).forEach((a) => all.push(a));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
|
||||
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
|
||||
assert.equal(t.TAB_LABELS.console, "Console Spark");
|
||||
});
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
|
||||
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(prod.agent, "https://agent.dracodev.net/");
|
||||
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
|
||||
const explicit = t.resolveUrls({
|
||||
hostname: "tiktak-dev.dracodev.net",
|
||||
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||
});
|
||||
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||
const href = t.resolveUrls({
|
||||
hostname: "example.org",
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
|
||||
});
|
||||
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||
assert.equal(href.agent, "https://agent.dracodev.net/");
|
||||
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
|
||||
});
|
||||
|
||||
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].state, "active");
|
||||
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
|
||||
assert.equal(items[1].active, false);
|
||||
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
|
||||
});
|
||||
|
||||
test("normalizeTiktak: lien tache, sinon page notifications", () => {
|
||||
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]);
|
||||
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
|
||||
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
|
||||
assert.equal(items[0].active, true);
|
||||
});
|
||||
|
||||
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
|
||||
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.ok(items.every((i) => i.active === false));
|
||||
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
|
||||
assert.match(items[0].title, /#69/);
|
||||
});
|
||||
|
||||
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||
const list = [
|
||||
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
|
||||
{ app: "kanbante", active: false, date: "" },
|
||||
];
|
||||
const sorted = t.sortAlerts(list);
|
||||
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
|
||||
assert.equal(sorted[0].active, true);
|
||||
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
|
||||
assert.equal(sorted[3].date, "");
|
||||
assert.equal(t.countActive(list), 2);
|
||||
});
|
||||
|
||||
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r);
|
||||
assert.equal(tout.items.length, 2);
|
||||
assert.ok(tout.items.every((a) => a.active));
|
||||
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
|
||||
const fd = t.scopeAlerts(all, "flowdeck", r);
|
||||
assert.equal(fd.items.length, 1);
|
||||
assert.equal(fd.items[0].app, "flowdeck");
|
||||
const hist = t.scopeAlerts(all, "historique", r);
|
||||
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
|
||||
assert.ok(hist.items.every((a) => !a.active));
|
||||
const og = t.scopeAlerts(all, "obsigate", r);
|
||||
assert.equal(og.items.length, 0);
|
||||
const cs = t.scopeAlerts(all, "console", r);
|
||||
assert.equal(cs.items.length, 0);
|
||||
assert.equal(cs.consoleMissing, true);
|
||||
});
|
||||
|
||||
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
|
||||
const r = sampleResults();
|
||||
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
|
||||
const hist = t.scopeAlerts(allOf(r), "historique", r);
|
||||
assert.equal(hist.errors.length, 1);
|
||||
assert.equal(hist.errors[0].app, "flowdeck");
|
||||
const tout = t.scopeAlerts(allOf(r), "tout", r);
|
||||
assert.equal(tout.errors.length, 1);
|
||||
});
|
||||
|
||||
test("countByTab: compteurs par onglet sur l'echantillon", () => {
|
||||
const r = sampleResults();
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.deepEqual(c, {
|
||||
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
|
||||
kanbante: 0, console: 0, historique: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r).items;
|
||||
assert.equal(t.filterAlerts(tout, "budget").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
|
||||
assert.equal(t.filterAlerts(tout, "tache").length, 1);
|
||||
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
|
||||
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
|
||||
const hist = t.scopeAlerts(all, "historique", r).items;
|
||||
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "").length, tout.length);
|
||||
});
|
||||
|
||||
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
|
||||
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
|
||||
})[0];
|
||||
assert.ok(!t.metaLine(kb).includes("moved"));
|
||||
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
|
||||
assert.equal(t.displayState({ active: true }), "Active");
|
||||
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
|
||||
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
|
||||
assert.match(t.metaLine(sansDate), /sans date/);
|
||||
});
|
||||
|
||||
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
|
||||
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
|
||||
assert.equal(t.parseAlertTab("#alertes=console"), "console");
|
||||
assert.equal(t.parseAlertTab("#alertes"), "tout");
|
||||
assert.equal(t.parseAlertTab("#autre"), null);
|
||||
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
|
||||
assert.equal(t.parseAlertTab(""), null);
|
||||
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
|
||||
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
|
||||
});
|
||||
|
||||
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
|
||||
const items = t.normalizeConsole("https://agent.dracodev.net/", {
|
||||
alerts: [
|
||||
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
|
||||
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
|
||||
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
|
||||
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
|
||||
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 5);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[1].active, true);
|
||||
assert.equal(items[2].active, true);
|
||||
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
|
||||
assert.equal(items[3].active, false);
|
||||
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
|
||||
assert.equal(items[0].url, "https://agent.dracodev.net/");
|
||||
const r = sampleResults();
|
||||
r.console = { items };
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.equal(c.console, 4);
|
||||
assert.equal(c.tout, 6);
|
||||
assert.equal(c.historique, 5);
|
||||
});
|
||||
|
||||
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
|
||||
assert.equal(t.consoleSourceUrl({}), null);
|
||||
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
|
||||
"https://agent-dev.dracodev.net/api/alertes");
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/* Tests Node sans navigateur de recherche.js (logique pure).
|
||||
* Usage : node recherche.test.js — sortie 0 si tout passe. */
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const rp = require("../static/js/recherche.js");
|
||||
|
||||
let n = 0;
|
||||
function ok(cond, label) {
|
||||
assert.ok(cond, label);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
/* esc */
|
||||
ok(rp.esc('<a href="x">&') === "<a href="x">&", "esc échappe HTML");
|
||||
|
||||
/* isDevHost : même règle que la barre v0.1.6 */
|
||||
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
|
||||
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
|
||||
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
|
||||
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
|
||||
|
||||
/* resolveBases : ordre explicite > barre > dev > href > prod */
|
||||
let b = rp.resolveBases({
|
||||
host: "tiktak-dev.dracodev.net",
|
||||
explicit: { obsigate: "https://og-perso.example/" },
|
||||
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
|
||||
tabHrefs: { kanbante: "https://kanbante-href.example/" },
|
||||
});
|
||||
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
|
||||
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
|
||||
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
|
||||
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
|
||||
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
|
||||
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
|
||||
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
|
||||
b = rp.resolveBases({});
|
||||
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
|
||||
|
||||
/* searchPath */
|
||||
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
|
||||
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
|
||||
|
||||
/* normalize ObsiGate */
|
||||
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
|
||||
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
|
||||
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
|
||||
|
||||
/* normalize Flowdeck */
|
||||
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
|
||||
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
|
||||
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
|
||||
|
||||
/* normalize TikTak */
|
||||
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
|
||||
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
|
||||
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
|
||||
|
||||
/* normalize Kanbante */
|
||||
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
|
||||
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)");
|
||||
|
||||
|
||||
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
|
||||
|
||||
/* parseTab */
|
||||
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
|
||||
ok(rp.parseTab("shaarit") === "shaarit", "onglet shaarit valide");
|
||||
ok(rp.parseTab("console") === "console", "onglet console valide");
|
||||
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
|
||||
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
|
||||
|
||||
/* visibleKeys : Tout = les 5 sections groupées, sans la console */
|
||||
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "Tout = 5 sections, pas la console");
|
||||
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("shaarit")) === JSON.stringify(["shaarit"]), "onglet shaarit = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "onglet inconnu -> Tout");
|
||||
|
||||
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
|
||||
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
|
||||
|
||||
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
|
||||
let c = rp.tabCounts({
|
||||
obsigate: { status: "loading", total: 0 },
|
||||
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");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "ok", total: 5 },
|
||||
flowdeck: { status: "ok", total: 2 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
shaarit: { status: "ok", total: 1 },
|
||||
console: { status: "ok", total: 4 },
|
||||
});
|
||||
ok(c.all === "8", "Tout = somme des totaux connus (5 sections)");
|
||||
ok(c.console === "4", "compteur console");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "error", total: 0 },
|
||||
flowdeck: { status: "error", total: 0 },
|
||||
tiktak: { status: "error", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
shaarit: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.all === "", "Tout vide si les 5 sections sont en erreur");
|
||||
|
||||
/* buildSearchUrl : état onglet partageable */
|
||||
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
|
||||
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
|
||||
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
|
||||
|
||||
/* searchPath complet (onglet) vs tronqué (Tout) */
|
||||
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
|
||||
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
|
||||
|
||||
/* normalize complet vs tronqué */
|
||||
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
|
||||
|
||||
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
|
||||
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
|
||||
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
|
||||
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
|
||||
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
|
||||
|
||||
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
|
||||
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
|
||||
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
|
||||
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
|
||||
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
|
||||
|
||||
|
||||
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
|
||||
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
|
||||
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
|
||||
let rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
|
||||
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
|
||||
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
|
||||
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
|
||||
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
|
||||
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
|
||||
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
|
||||
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
|
||||
] }, "https://agent-dev.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
|
||||
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
|
||||
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
|
||||
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
|
||||
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
|
||||
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
|
||||
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
|
||||
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
|
||||
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
|
||||
rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
|
||||
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
|
||||
] }, "https://agent.dracodev.net/");
|
||||
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
|
||||
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
|
||||
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
/* federationAutorisee (HUB-21) : seules les valeurs explicitement
|
||||
négatives désactivent la fédération ; absent/illisible = actif
|
||||
(repli = comportement actuel, Hub muet compris). */
|
||||
ok(rp.federationAutorisee("false") === false, "fédération : false désactive");
|
||||
ok(rp.federationAutorisee("0") === false, "fédération : 0 désactive");
|
||||
ok(rp.federationAutorisee(" FALSE ") === false, "fédération : casse/espaces ignorés");
|
||||
ok(rp.federationAutorisee("true") === true, "fédération : true active");
|
||||
ok(rp.federationAutorisee("1") === true, "fédération : 1 active");
|
||||
ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif");
|
||||
ok(rp.federationAutorisee(undefined) === true, "fédération : undefined = actif");
|
||||
ok(rp.federationAutorisee("") === true, "fédération : vide = actif");
|
||||
ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Flowdeck).
|
||||
|
||||
Source réelle de Flowdeck : GET /api/notifications (non lues =
|
||||
actives, `url` relative pour le lien profond), déjà exposée et
|
||||
inchangée ici. Couverts : placeholder Alertes disparu, module servi,
|
||||
CSP connect-src avec les origines soeurs, CORS (origines soeurs
|
||||
ajoutées à allow_origins ; tiers sans en-tête).
|
||||
"""
|
||||
|
||||
SOEURS = ("https://og.dracodev.net", "https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net", "https://kanbante.dracodev.net")
|
||||
|
||||
|
||||
def test_barre_alertes_sans_placeholder_et_module_servi(client):
|
||||
# Les pages applicatives authentifiées ne sont pas atteignables
|
||||
# telles quelles dans la sandbox (l'écran de connexion est servi
|
||||
# pour "/" comme pour "/dashboard") : on vérifie donc le gabarit
|
||||
# livré lui-même, plus les fichiers statiques servis.
|
||||
from pathlib import Path
|
||||
|
||||
gabarit = Path("app/templates/_draco_bar.html").read_text()
|
||||
assert "Alertes unifiées : arrive avec la fédération" not in gabarit
|
||||
assert "draco-alertes.js" in gabarit
|
||||
assert "draco-alertes.css" in gabarit
|
||||
assert "DRACO_ALERTES.open()" in gabarit
|
||||
js = client.get("/static/js/draco-alertes.js")
|
||||
assert js.status_code == 200
|
||||
assert "DRACO_ALERTES" in js.text
|
||||
assert "api/notifications" in js.text
|
||||
css = client.get("/static/css/draco-alertes.css")
|
||||
assert css.status_code == 200
|
||||
assert "draco-alertes-overlay" in css.text
|
||||
|
||||
|
||||
def test_csp_connect_src_contient_les_soeurs(client):
|
||||
# La CSP est posée sur toute réponse HTML, y compris l'écran de
|
||||
# connexion servi ici : elle prouve les origines soeurs en
|
||||
# connect-src pour les pages de l'hôte.
|
||||
r = client.get("/")
|
||||
csp = r.headers.get("content-security-policy", "")
|
||||
assert "connect-src" in csp
|
||||
for origine in SOEURS:
|
||||
assert origine in csp, csp
|
||||
|
||||
|
||||
def test_notifications_api_et_cors_soeur(client):
|
||||
r = client.get("/api/notifications")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert isinstance(body["notifications"], list)
|
||||
assert "unread" in body
|
||||
r2 = client.options("/api/notifications",
|
||||
headers={"Origin": "https://tiktak.dracodev.net",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r2.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
|
||||
assert r2.headers.get("access-control-allow-credentials") == "true"
|
||||
r3 = client.get("/api/notifications",
|
||||
headers={"Origin": "https://tiktak.dracodev.net"})
|
||||
assert r3.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
|
||||
r4 = client.options("/api/notifications",
|
||||
headers={"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert "access-control-allow-origin" not in r4.headers
|
||||
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
|
||||
r5 = client.options("/api/notifications",
|
||||
headers={"Origin": "https://tiktak-dev.dracodev.net",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r5.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
|
||||
assert r5.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
|
||||
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
|
||||
"""Chantier 5 : onglet Console Spark en dernier dans la barre
|
||||
(gabarit lu, pages authentifiées inatteignables en sandbox comme
|
||||
pour le test #69), et vue Alertes en onglets (module + styles)."""
|
||||
from pathlib import Path
|
||||
|
||||
gabarit = Path("app/templates/_draco_bar.html").read_text()
|
||||
nav = gabarit.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
|
||||
assert "Console Spark" in nav
|
||||
js = client.get("/static/js/draco-alertes.js")
|
||||
assert js.status_code == 200
|
||||
assert "draco-alertes-tab" in js.text
|
||||
assert "historique" in js.text
|
||||
assert "source indisponible" in js.text
|
||||
css = client.get("/static/css/draco-alertes.css")
|
||||
assert css.status_code == 200
|
||||
assert "draco-alertes-tab" in css.text
|
||||
assert "draco-alertes-filtre" in css.text
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Barre d'apps Flowdeck : aucun texte de chantier rendu hors commentaire.
|
||||
|
||||
Defaut 5 : les lignes « Chantier 5 (fusion #69/#71, sans bump) : ... »
|
||||
etaient placees APRES la fermeture `#}` du commentaire Jinja d'en-tete
|
||||
de `app/templates/_draco_bar.html` (lignes 9-11) : elles etaient donc
|
||||
rendues telles quelles, juste au-dessus du <header> de la barre, sur
|
||||
toutes les pages (la barre est incluse par base.html).
|
||||
|
||||
Test sans dependance (lecture du gabarit seul, comme
|
||||
test_draco_alertes.py) : il tourne sans serveur ni base, donc aussi
|
||||
en sandbox. La note documentaire peut vivre DANS le commentaire Jinja
|
||||
(jamais rendu) ; elle ne doit jamais en sortir.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _hors_commentaires(texte):
|
||||
"""Texte du gabarit sans les commentaires Jinja {# ... #}."""
|
||||
return re.sub(r"\{#.*?#\}", "", texte, flags=re.S)
|
||||
|
||||
|
||||
def test_aucun_texte_chantier_rendu():
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
assert "Chantier 5 (fusion" not in rendu
|
||||
assert "sans bump" not in rendu
|
||||
# Aucune ligne de texte libre entre le commentaire d'en-tete et <link> :
|
||||
# le rendu doit commencer (apres commentaires) par <link> ou <header>.
|
||||
assert rendu.lstrip().startswith("<link")
|
||||
|
||||
|
||||
def test_barre_v020_onglet_shaarit_et_garde_data_theme():
|
||||
"""Barre v0.2.0 (commons @70c837a) : onglet ShaarIt entre Kanbante
|
||||
et l'onglet agent, logo fourni tel quel, CSS vendangé verbatim
|
||||
avec la garde data-theme sur prefers-color-scheme."""
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
nav = rendu.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="shaarit"')
|
||||
assert nav.index('data-app="shaarit"') < nav.index('data-app="agent"')
|
||||
assert 'href="{{ draco_urls().shaarit }}/"' in nav
|
||||
assert 'data-icon="shaarit"><img src="/static/img/logos/shaarit.svg"' in nav
|
||||
assert (ROOT / "static/img/logos/shaarit.svg").is_file()
|
||||
css = (ROOT / "static/css/draco-barre.css").read_text()
|
||||
assert "html:not([data-theme]) .draco-bar {" in css
|
||||
assert "html:not([data-theme]) .draco-bar .draco-brand-mark img" in css
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert 'shaarit: "https://shaarit.dracodev.net/"' in js
|
||||
assert 'shaarit: "https://shaarit-dev.dracodev.net/"' in js
|
||||
|
||||
|
||||
def test_barre_v030_liens_hub_menu_compte():
|
||||
"""Barre v0.3.0 (commons, HUB-1 / fusion #115) : JS vendangé avec
|
||||
la clé hub (défaut + dérivation hub-dev) et les liens du menu
|
||||
compte ; le gabarit passe draco_urls().hub en urls.hub."""
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
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
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
assert 'urls: { hub: "{{ draco_urls().hub }}/" }' in rendu
|
||||
|
||||
|
||||
def test_barre_v040_icones_menu_compte():
|
||||
"""Barre v0.4.0 (commons, HUB-11 / fusion #125) : les items du
|
||||
menu compte portent des icônes SVG monoline (construites en DOM,
|
||||
currentColor, sans remplissage) ; le libellé reste un nœud
|
||||
texte ; l'en-tête d'identité reste sans icône."""
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
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
|
||||
css = (ROOT / "static/css/draco-barre.css").read_text()
|
||||
assert ".draco-menu-icone" in css
|
||||
assert "width: 18px" in css
|
||||
|
||||
|
||||
def test_barre_v041_correctif_icones_espace_de_noms_svg():
|
||||
"""Barre v0.4.1 (commons, correctif fusion #125) : les icônes
|
||||
du menu compte sont créées via createElementNS (espace de
|
||||
noms SVG). En v0.4.0, createElement() les créait dans
|
||||
l'espace de noms HTML : jamais dessinées par le navigateur,
|
||||
libellés décalés d'un cran d'icône sans aucun glyphe."""
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert 'createElementNS(SVG_NS, "svg")' in js
|
||||
assert "createElementNS(SVG_NS, spec[0])" in js
|
||||
assert 'createElement("svg")' not in js
|
||||
assert "createElement(spec[0])" not in js
|
||||
|
||||
|
||||
def test_barre_intacte_apres_suppression():
|
||||
"""Le 5e onglet Console Spark et la marque survivent a la suppression."""
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
nav = rendu.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
|
||||
assert "Console Spark" in nav
|
||||
assert 'data-app="agent"' in nav
|
||||
assert 'class="draco-brand"' in rendu
|
||||
# L'entree « Console Spark » du menu de la marque est construite par
|
||||
# draco-barre.js depuis les onglets : l'onglet agent suffit a l'alimenter.
|
||||
js_barre = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert "Console Spark" in js_barre
|
||||
|
||||
|
||||
def test_barre_v050_theme_commun_hub18():
|
||||
# v0.5.0 (HUB-18, fusion #132) : la barre apprend la préférence
|
||||
# de thème du Hub et l'applique à l'ouverture. Vendange verbatim
|
||||
# commons ; l'initialisation propre à Flowdeck vit dans app.js
|
||||
# (écouteur draco:theme → data-theme + clé locale « theme »).
|
||||
js = (ROOT / "static/js/vendor/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
|
||||
app = (ROOT / "static/js/app.js").read_text(encoding="utf-8")
|
||||
assert 'addEventListener("draco:theme"' in app or "addEventListener('draco:theme'" in app
|
||||
@@ -12,7 +12,8 @@ import pytest
|
||||
from app.services import draco_urls
|
||||
|
||||
VARS = ("DRACO_SSO_URL", "DRACO_AGENT_URL", "TIKTAK_URL", "KANBANTE_URL",
|
||||
"FLOWDECK_URL", "OAUTH_REDIRECT_URI", "AGENT_EVENEMENTS_URL")
|
||||
"SHAARIT_URL", "HUB_URL", "FLOWDECK_URL", "OAUTH_REDIRECT_URI",
|
||||
"AGENT_EVENEMENTS_URL")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -45,6 +46,41 @@ def test_surcharge_dev_par_env(monkeypatch):
|
||||
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
|
||||
|
||||
|
||||
def test_shaarit_defaut_derivation_et_explicite(monkeypatch, avec_hote):
|
||||
# Barre v0.2.0 : ShaarIt suit le même mécanisme que les autres
|
||||
# sœurs — défaut officiel, dérivation -dev (agent connu puis
|
||||
# hôte de la requête), explicite prioritaire.
|
||||
assert draco_urls.shaarit_url() == "https://shaarit.dracodev.net"
|
||||
assert draco_urls.toutes()["shaarit"] == "https://shaarit.dracodev.net"
|
||||
monkeypatch.setenv(
|
||||
"AGENT_EVENEMENTS_URL",
|
||||
"https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
assert draco_urls.shaarit_url() == "https://shaarit-dev.dracodev.net"
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_URL")
|
||||
avec_hote("flowdeck-dev.dracodev.net")
|
||||
assert draco_urls.shaarit_url() == "https://shaarit-dev.dracodev.net"
|
||||
monkeypatch.setenv("SHAARIT_URL", "https://bm.example.net/")
|
||||
assert draco_urls.shaarit_url() == "https://bm.example.net"
|
||||
|
||||
|
||||
def test_hub_defaut_derivation_et_explicite(monkeypatch, avec_hote):
|
||||
# Barre v0.3.0 (HUB-1) : le Hub suit le même mécanisme que les
|
||||
# autres sœurs — défaut officiel, dérivation -dev (agent connu
|
||||
# puis hôte de la requête), explicite prioritaire. Pas d'onglet :
|
||||
# sa base sert les liens du menu compte via urls.hub.
|
||||
assert draco_urls.hub_url() == "https://hub.dracodev.net"
|
||||
assert draco_urls.toutes()["hub"] == "https://hub.dracodev.net"
|
||||
monkeypatch.setenv(
|
||||
"AGENT_EVENEMENTS_URL",
|
||||
"https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
assert draco_urls.hub_url() == "https://hub-dev.dracodev.net"
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_URL")
|
||||
avec_hote("flowdeck-dev.dracodev.net")
|
||||
assert draco_urls.hub_url() == "https://hub-dev.dracodev.net"
|
||||
monkeypatch.setenv("HUB_URL", "https://hub.example.net/")
|
||||
assert draco_urls.hub_url() == "https://hub.example.net"
|
||||
|
||||
|
||||
def test_flowdeck_url_derivée_oauth_redirect(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"OAUTH_REDIRECT_URI", "https://flowdeck-dev.dracodev.net/auth/callback")
|
||||
|
||||
@@ -4,7 +4,10 @@ Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
|
||||
(secret partagé, HS256), résolu vers le compte Flowdeck
|
||||
(draco_links, migration 51 ; repli mono-utilisateur), et converti en
|
||||
session native par GET /auth/draco. Sans DRACO_SESSION_SECRET, le
|
||||
pont renvoie simplement à la connexion locale.
|
||||
pont renvoie simplement à la connexion locale. HUB-15 ajoute le
|
||||
provisionnement JIT : aucun compte correspondant → le pendant local
|
||||
est créé depuis les claims (rôle standard, jamais admin, sans mot
|
||||
de passe local), sauf pierre tombale de dissociation.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -21,13 +24,13 @@ from app.services import draco
|
||||
SECRET = "secret-partage-de-test"
|
||||
|
||||
|
||||
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"):
|
||||
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test", name="Bruno"):
|
||||
def b64(d: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
|
||||
|
||||
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||
payload = b64(json.dumps({
|
||||
"sub": sub, "name": "Bruno", "iat": int(time.time()),
|
||||
"sub": sub, "name": name, "iat": int(time.time()),
|
||||
"exp": exp if exp is not None else int(time.time()) + 3600,
|
||||
"jti": jti,
|
||||
}).encode())
|
||||
@@ -101,13 +104,74 @@ def test_lien_requis_en_multi_utilisateurs(client):
|
||||
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
||||
"VALUES ('deuxieme', '[email protected]', 'x', 'Deuxième', 1)")
|
||||
conn.commit()
|
||||
# Sans lien et avec deux utilisateurs actifs : le pont refuse.
|
||||
client.cookies.set("draco_session", _jeton(sub="og-inconnu"))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.headers["location"] == "/auth/login?provider=local"
|
||||
# Le lien explicite (créé pendant une visite connectée) rouvre la porte.
|
||||
# Le lien explicite (créé pendant une visite connectée) ouvre la porte.
|
||||
client.cookies.set("draco_session", _jeton(sub="og-bruno-1"))
|
||||
assert client.post("/auth/draco-link").json()["sub"] == "og-bruno-1"
|
||||
client.cookies.delete("flowdeck_session")
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
|
||||
|
||||
# ── HUB-15 : provisionnement JIT ─────────────────────────────────────
|
||||
|
||||
def _ajoute_deuxieme_utilisateur():
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
||||
"VALUES ('deuxieme', '[email protected]', 'x', 'Deuxième', 1)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _compte_utilisateurs():
|
||||
with get_conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
def test_jit_cree_le_pendant_local_en_multi_utilisateurs(client):
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
# Sub inconnu, sans lien, en multi-utilisateurs : avant HUB-15 le
|
||||
# pont refusait ; désormais le pendant local est créé en JIT.
|
||||
client.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle"))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
assert _compte_utilisateurs() == avant + 1
|
||||
with get_conn() as conn:
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE login = 'draco-og-nouveau'").fetchone()
|
||||
assert user is not None
|
||||
assert user["full_name"] == "Nouvelle"
|
||||
assert user["is_admin"] == 0 # jamais admin
|
||||
assert user["is_active"] == 1
|
||||
assert user["password_hash"] is None # aucun mot de passe local
|
||||
assert user["auth_method"] == "draco"
|
||||
lien = conn.execute(
|
||||
"SELECT * FROM draco_links WHERE sub = 'og-nouveau'").fetchone()
|
||||
assert lien is not None and lien["user_id"] == user["id"]
|
||||
assert lien["unlinked_at"] is None
|
||||
# Deuxième arrivée : même compte réutilisé, aucune création de plus.
|
||||
client.cookies.delete("flowdeck_session")
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
assert _compte_utilisateurs() == avant + 1
|
||||
# Le compte JIT ne peut pas se connecter par mot de passe local.
|
||||
r = client.post("/auth/local-login",
|
||||
json={"email": "draco-og-nouveau", "password": "nimporte"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_jit_pierre_tombale_sans_recreation(client):
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
client.cookies.set("draco_session", _jeton(sub="og-ephemere"))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.headers["location"] == "/workspaces" # compte JIT créé
|
||||
# La session courante est celle du compte JIT : dissociation.
|
||||
r = client.delete("/auth/draco-link")
|
||||
assert r.json()["ok"] is True
|
||||
apres_creation = _compte_utilisateurs()
|
||||
# Nouvelle arrivée du même sub : la pierre tombale interdit toute
|
||||
# re-création automatique — retour à la connexion locale.
|
||||
client.cookies.delete("flowdeck_session")
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.headers["location"] == "/auth/login?provider=local"
|
||||
assert _compte_utilisateurs() == apres_creation
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""HUB-21 — réglages généraux du Hub consommés par Flowdeck.
|
||||
|
||||
Propagation du pilote TikTak 0.61.22 : client serveur
|
||||
``app.services.hub_reglages`` (cache 60 s, horodatage posé avant
|
||||
l'appel, repli intégral sur les défauts actuels), bannière
|
||||
d'annonce dans le gabarit principal (texte échappé par
|
||||
l'autoescape Jinja) et drapeau ``recherche_partout`` rendu en
|
||||
attribut ``data-recherche-partout`` de la page /recherche (la
|
||||
logique JS associée est couverte par tests/recherche.test.js).
|
||||
``domaine_defaut`` n'est pas appliqué : Flowdeck n'a pas de
|
||||
notion de domaine propre (voir le docstring du module).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import hub_reglages
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cache_propre():
|
||||
hub_reglages.reinitialiser_cache()
|
||||
yield
|
||||
hub_reglages.reinitialiser_cache()
|
||||
|
||||
|
||||
def _pose_reglages(monkeypatch, reglages, compteur=None):
|
||||
"""Simule la consultation du Hub (appel réseau évité)."""
|
||||
def faux_consulter():
|
||||
if compteur is not None:
|
||||
compteur.append(1)
|
||||
return reglages
|
||||
monkeypatch.setattr(hub_reglages, "_consulter_hub", faux_consulter)
|
||||
|
||||
|
||||
# ── Client : validation, cache, repli ───────────────────────────────
|
||||
|
||||
def test_validation_forme_du_contrat():
|
||||
assert hub_reglages._valider({
|
||||
"recherche_partout": False, "banniere": "Maintenance dimanche",
|
||||
"domaine_defaut": "famille", "ttl_secondes": 60}) == {
|
||||
"recherche_partout": False, "banniere": "Maintenance dimanche",
|
||||
"domaine_defaut": "famille"}
|
||||
for charge in (
|
||||
None, "texte", ["x"], {},
|
||||
{"recherche_partout": "oui", "banniere": "", "domaine_defaut": None},
|
||||
{"recherche_partout": True, "banniere": 42, "domaine_defaut": None},
|
||||
{"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"},
|
||||
):
|
||||
assert hub_reglages._valider(charge) is None, charge
|
||||
|
||||
|
||||
class _Reponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
||||
import httpx
|
||||
|
||||
from app.services import draco_urls
|
||||
monkeypatch.setattr(draco_urls, "hub_url",
|
||||
lambda: "https://hub.example")
|
||||
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
if appels is not None:
|
||||
appels.append((url, headers, timeout))
|
||||
if isinstance(reponse_ou_exception, Exception):
|
||||
raise reponse_ou_exception
|
||||
return reponse_ou_exception
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
|
||||
|
||||
def test_consultation_contrat_exact(monkeypatch):
|
||||
appels = []
|
||||
_pose_http(monkeypatch, _Reponse(200, {
|
||||
"recherche_partout": True, "banniere": "Annonce",
|
||||
"domaine_defaut": None, "ttl_secondes": 60}), appels)
|
||||
assert hub_reglages._consulter_hub() == {
|
||||
"recherche_partout": True, "banniere": "Annonce",
|
||||
"domaine_defaut": None}
|
||||
url, headers, timeout = appels[0]
|
||||
assert url == "https://hub.example/api/reglages/effectifs"
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert "Cookie" not in headers # endpoint public
|
||||
assert timeout == 2.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reponse", [
|
||||
_Reponse(500, {}),
|
||||
_Reponse(200, {"recherche_partout": "oui", "banniere": "",
|
||||
"domaine_defaut": None}),
|
||||
_Reponse(200, ValueError("json illisible")),
|
||||
ConnectionError("hub injoignable"),
|
||||
])
|
||||
def test_consultation_erreurs_repli_none(monkeypatch, reponse):
|
||||
_pose_http(monkeypatch, reponse)
|
||||
assert hub_reglages._consulter_hub() is None
|
||||
|
||||
|
||||
def test_hub_muet_repli_defauts(monkeypatch):
|
||||
_pose_reglages(monkeypatch, None)
|
||||
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||
assert hub_reglages.banniere() == ""
|
||||
assert hub_reglages.recherche_partout() is True
|
||||
|
||||
|
||||
def test_cache_60_s(monkeypatch):
|
||||
appels = []
|
||||
_pose_reglages(monkeypatch, {
|
||||
"recherche_partout": False, "banniere": "Annonce A",
|
||||
"domaine_defaut": None}, appels)
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert hub_reglages.recherche_partout() is False
|
||||
assert len(appels) == 1 # cache 60 s
|
||||
hub_reglages._cache_at = time.monotonic() - 61
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert len(appels) == 2 # expiration → rappel
|
||||
|
||||
|
||||
def test_echec_ancien_cache_en_vigueur(monkeypatch):
|
||||
appels = []
|
||||
_pose_reglages(monkeypatch, {
|
||||
"recherche_partout": True, "banniere": "Annonce A",
|
||||
"domaine_defaut": None}, appels)
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
hub_reglages._cache_at = time.monotonic() - 61
|
||||
_pose_reglages(monkeypatch, None, appels) # Hub tombe
|
||||
assert hub_reglages.banniere() == "Annonce A" # ancien cache
|
||||
assert len(appels) == 2 # pas de rafale
|
||||
|
||||
|
||||
# ── Intégration : bannière et drapeau dans les pages ────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def hub_banniere(monkeypatch):
|
||||
etat = {"reglages": None}
|
||||
monkeypatch.setattr(
|
||||
hub_reglages, "_consulter_hub", lambda: etat["reglages"])
|
||||
hub_reglages.reinitialiser_cache()
|
||||
return etat
|
||||
|
||||
|
||||
def test_banniere_presente_et_echappee(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True,
|
||||
"banniere": "Maintenance dimanche <b>gras</b> & cie",
|
||||
"domaine_defaut": None}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert 'id="banniere-hub"' in r.text
|
||||
assert "Maintenance dimanche <b>gras</b> & cie" in r.text
|
||||
assert "sessionStorage" in r.text
|
||||
assert "flowdeck_banniere_masquee" in r.text
|
||||
|
||||
|
||||
def test_banniere_absente_si_vide_ou_hub_muet(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True, "banniere": "", "domaine_defaut": "pro"}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert "banniere-hub" not in r.text
|
||||
hub_banniere["reglages"] = None
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert "banniere-hub" not in r.text
|
||||
|
||||
|
||||
def test_recherche_drapeau_rendu_dans_la_coquille(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": False, "banniere": "", "domaine_defaut": None}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert 'data-recherche-partout="false"' in r.text
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True, "banniere": "", "domaine_defaut": None}
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert 'data-recherche-partout="true"' in r.text
|
||||
hub_banniere["reglages"] = None # Hub muet → défaut actif
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert 'data-recherche-partout="true"' in r.text
|
||||
|
||||
|
||||
def test_version_7_85_3():
|
||||
from pathlib import Path
|
||||
version = (Path(__file__).parent.parent / "VERSION").read_text().strip()
|
||||
assert version == "7.85.3"
|
||||
@@ -0,0 +1,308 @@
|
||||
"""HUB-19 — propagation des rôles du Hub à Flowdeck (suite du pilote
|
||||
ShaarIt 0.7.0) : client ``app.services.hub_roles`` et ses effets.
|
||||
|
||||
Contrat D3(a) : à l'ouverture de session draco, consultation de
|
||||
``GET {hub}/api/roles/flowdeck`` (cookie complet relayé, délai 2 s),
|
||||
cache mémoire par ``sub`` TTL 60 s, horodatage posé avant l'appel.
|
||||
Toute erreur = rôle inconnu → comportement actuel conservé.
|
||||
|
||||
Effets vérifiés ici :
|
||||
- rôle connu ``aucun`` → 403 en français au pont ``GET /auth/draco``,
|
||||
avant la création de la session locale et avant le provisionnement
|
||||
JIT HUB-15 (aucun compte créé pour un ``sub`` refusé) ;
|
||||
- session locale préexistante exemptée (accès de secours) ;
|
||||
- surfaces d'administration d'instance (``/api/admin/*``) : session
|
||||
draco + rôle connu → ``administrateur`` exigé ;
|
||||
- rôle inconnu (Hub injoignable/erreur/rôle hors ensemble) → repli
|
||||
intégral.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import draco, hub_roles
|
||||
|
||||
SECRET = "secret-partage-de-test"
|
||||
|
||||
|
||||
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-hub19", name="Bruno"):
|
||||
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": name, "iat": int(time.time()),
|
||||
"exp": exp if exp is not None else int(time.time()) + 3600,
|
||||
"jti": jti,
|
||||
}).encode())
|
||||
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
||||
hashlib.sha256).digest()
|
||||
return f"{header}.{payload}.{b64(sig)}"
|
||||
|
||||
|
||||
def _requete(cookie_complet="", draco_token=None):
|
||||
cookies = {}
|
||||
headers = {}
|
||||
if draco_token is not None:
|
||||
cookies["draco_session"] = draco_token
|
||||
complet = f"draco_session={draco_token}"
|
||||
headers["cookie"] = complet if not cookie_complet else cookie_complet
|
||||
elif cookie_complet:
|
||||
headers["cookie"] = cookie_complet
|
||||
return SimpleNamespace(cookies=cookies, headers=headers)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _caches_propres(monkeypatch):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||
draco._revoked_cache.clear()
|
||||
hub_roles._cache.clear()
|
||||
hub_roles._cache_fetched_at = 0.0
|
||||
yield
|
||||
draco._revoked_cache.clear()
|
||||
hub_roles._cache.clear()
|
||||
hub_roles._cache_fetched_at = 0.0
|
||||
|
||||
|
||||
def _pose_role(monkeypatch, role, compteur=None):
|
||||
"""Simule la réponse du Hub pour ``role_pour`` (appel réseau évité)."""
|
||||
def faux_consulter(cookie):
|
||||
if compteur is not None:
|
||||
compteur.append(cookie)
|
||||
return role
|
||||
monkeypatch.setattr(hub_roles, "_consulter_hub", faux_consulter)
|
||||
|
||||
|
||||
# ── Client : cache par sub, TTL, horodatage avant l'appel ───────────
|
||||
|
||||
def test_role_connu_mis_en_cache_par_sub(monkeypatch):
|
||||
appels = []
|
||||
_pose_role(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
||||
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 1 # 2e lecture : cache
|
||||
assert hub_roles.role_pour("cookie-b", "sub-2") == "utilisateur"
|
||||
assert len(appels) == 2 # autre sub : nouvel appel
|
||||
|
||||
|
||||
def test_echec_ancien_cache_conserve_pas_de_rafale(monkeypatch):
|
||||
appels = []
|
||||
_pose_role(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
# Fenêtre expirée, Hub désormais en échec : l'ancien cache reste.
|
||||
hub_roles._cache_fetched_at = time.monotonic() - 61
|
||||
_pose_role(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 2
|
||||
# Pas de rafale : dans la fenêtre qui suit l'échec, aucun rappel.
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_echec_sans_cache_role_inconnu(monkeypatch):
|
||||
# Fidèle au pilote : sans entrée en cache pour ce sub, l'échec
|
||||
# rend « inconnu » et la demande suivante retente (la protection
|
||||
# anti-rafale de l'horodatage posé avant l'appel ne sert que les
|
||||
# subs déjà en cache — test précédent).
|
||||
appels = []
|
||||
_pose_role(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("c", "sub-x") is None
|
||||
assert hub_roles.role_pour("c", "sub-x") is None
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_role_sans_sub_inconnu(monkeypatch):
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
assert hub_roles.role_pour("c", "") is None
|
||||
|
||||
|
||||
# ── Client : validation de la réponse du Hub ────────────────────────
|
||||
|
||||
class _Reponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
||||
import httpx
|
||||
|
||||
from app.services import draco_urls
|
||||
monkeypatch.setattr(draco_urls, "hub_url", lambda: "https://hub.example")
|
||||
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
if appels is not None:
|
||||
appels.append((url, headers, timeout))
|
||||
if isinstance(reponse_ou_exception, Exception):
|
||||
raise reponse_ou_exception
|
||||
return reponse_ou_exception
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
|
||||
|
||||
def test_consultation_contrat_exact(monkeypatch):
|
||||
appels = []
|
||||
_pose_http(monkeypatch, _Reponse(200, {
|
||||
"app": "flowdeck", "sub": "sub-1", "role": "administrateur",
|
||||
"explicite": True, "ttl_secondes": 60}), appels)
|
||||
cookie = "draco_session=jeton; autre=1"
|
||||
assert hub_roles._consulter_hub(cookie) == "administrateur"
|
||||
url, headers, timeout = appels[0]
|
||||
assert url == "https://hub.example/api/roles/flowdeck"
|
||||
assert headers["Cookie"] == cookie # cookie complet relayé
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert timeout == 2.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reponse", [
|
||||
_Reponse(401, {}), _Reponse(404, {}), _Reponse(500, {}),
|
||||
_Reponse(200, {"role": "superadmin"}), # hors ensemble
|
||||
_Reponse(200, {"role": None}),
|
||||
_Reponse(200, ValueError("json illisible")),
|
||||
ConnectionError("hub injoignable"),
|
||||
])
|
||||
def test_consultation_erreurs_role_inconnu(monkeypatch, reponse):
|
||||
_pose_http(monkeypatch, reponse)
|
||||
assert hub_roles._consulter_hub("c") is None
|
||||
|
||||
|
||||
# ── role_draco / admin_hub_ok ───────────────────────────────────────
|
||||
|
||||
def test_role_draco_sans_cookie_inconnu():
|
||||
assert hub_roles.role_draco(_requete()) is None
|
||||
|
||||
|
||||
def test_role_draco_avec_cookie(monkeypatch):
|
||||
_pose_role(monkeypatch, "administrateur")
|
||||
assert hub_roles.role_draco(_requete(draco_token=_jeton())) == "administrateur"
|
||||
|
||||
|
||||
def test_admin_hub_ok_sans_draco_comportement_actuel():
|
||||
assert hub_roles.admin_hub_ok(_requete()) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role,attendu", [
|
||||
("administrateur", True), ("utilisateur", False), ("aucun", False),
|
||||
(None, True), # inconnu → repli
|
||||
])
|
||||
def test_admin_hub_ok_selon_role(monkeypatch, role, attendu):
|
||||
_pose_role(monkeypatch, role)
|
||||
assert hub_roles.admin_hub_ok(_requete(draco_token=_jeton())) is attendu
|
||||
|
||||
|
||||
# ── Intégration : pont /auth/draco ──────────────────────────────────
|
||||
|
||||
def _compte_utilisateurs():
|
||||
with get_conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
def _ajoute_deuxieme_utilisateur():
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
||||
"VALUES ('deuxieme', '[email protected]', 'x', 'Deuxième', 1)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_pont_refus_aucun_403_en_francais(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert "registre des rôles" in r.text and "Hub" in r.text
|
||||
assert "« aucun »" in r.text
|
||||
assert "flowdeck_session" not in r.headers.get("set-cookie", "")
|
||||
assert c.get("/auth/user").json()["authenticated"] is False
|
||||
|
||||
|
||||
def test_pont_refus_aucun_avant_provisionnement_jit(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
c.cookies.set("draco_session", _jeton(sub="og-refuse", name="Refusée"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
# Ordre vérifié : refus avant JIT — aucun compte, aucun lien créés.
|
||||
assert _compte_utilisateurs() == avant
|
||||
with get_conn() as conn:
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM draco_links WHERE sub = 'og-refuse'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_pont_role_utilisateur_jit_inchange(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
_pose_role(monkeypatch, "utilisateur")
|
||||
c.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
assert _compte_utilisateurs() == avant + 1 # JIT HUB-15 intact
|
||||
|
||||
|
||||
def test_pont_role_inconnu_repli_comportement_actuel(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_pose_role(monkeypatch, None) # Hub injoignable
|
||||
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
|
||||
|
||||
def test_pont_session_locale_preexistante_exemptee(client, monkeypatch):
|
||||
# Le client de la fixture porte déjà une session locale (admin de
|
||||
# test) : patron du pilote — l'accès de secours n'est pas refusé.
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
|
||||
|
||||
# ── Intégration : surfaces d'administration (/api/admin/*) ──────────
|
||||
|
||||
def test_admin_instance_role_utilisateur_refuse(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "utilisateur")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert "registre" in r.text and "Hub" in r.text
|
||||
|
||||
|
||||
def test_admin_instance_role_administrateur_accepte(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "administrateur")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_instance_role_inconnu_repli(client, monkeypatch):
|
||||
_pose_role(monkeypatch, None)
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200 # comportement actuel
|
||||
|
||||
|
||||
def test_admin_instance_sans_draco_inchange(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "aucun") # jamais consulté sans draco
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests de la recherche fédérée Flowdeck (fusion #68).
|
||||
|
||||
Fonctions pures de app/middleware/recherche_cors.py uniquement :
|
||||
aucune base, aucun serveur.
|
||||
"""
|
||||
|
||||
from app.middleware.recherche_cors import (
|
||||
cors_headers_for,
|
||||
is_dev_host,
|
||||
is_prod_host,
|
||||
origin_of,
|
||||
preflight_headers,
|
||||
sister_origins,
|
||||
)
|
||||
|
||||
RESOLVED_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/",
|
||||
"agent": "https://agent-dev.dracodev.net/",
|
||||
"gitea": "https://git.dracodev.net/",
|
||||
}
|
||||
|
||||
|
||||
def test_is_dev_host():
|
||||
assert is_dev_host("flowdeck-dev.dracodev.net") is True
|
||||
assert is_dev_host("flowdeck.dracodev.net") is False
|
||||
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||
assert is_dev_host("flowdeck-dev.dracodev.net:8443") is True
|
||||
assert is_prod_host("flowdeck.dracodev.net") is True
|
||||
assert is_prod_host("flowdeck-dev.dracodev.net") is False
|
||||
|
||||
|
||||
def test_origin_of():
|
||||
assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("{{URL_OBSIGATE}}") is None
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : origines officielles prod ET dev, quelle
|
||||
# que soit la résolution ; ShaarIt ajoutée par la fédération
|
||||
# v0.2.1 (12 origines : 10 sœurs + 2 agent).
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://shaarit.dracodev.net",
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://shaarit-dev.dracodev.net",
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent — l'hôte ne
|
||||
# restreint plus l'allowlist.
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck-dev.dracodev.net"
|
||||
)
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://og-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://og-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
# Autre environnement autorisé (correctif 2026-10-28).
|
||||
assert cors_headers_for("https://og.dracodev.net",
|
||||
RESOLVED_DEV) is not None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
headers = preflight_headers("https://kanbante-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Défaut 3b — /api/search Flowdeck depuis la console agent-dev.
|
||||
|
||||
Cause prouvée en déployé (flowdeck-dev, 2026-10-28) : sur une réponse
|
||||
GET /api/search, le CORSMiddleware global pose déjà
|
||||
``Access-Control-Allow-Credentials: true`` (comportement Starlette
|
||||
avec ``allow_credentials=True``, même hors allowlist), et l'ancien
|
||||
middleware de recherche EMPILAIT ses en-têtes par-dessus — d'où, pour
|
||||
l'origine agent-dev, ``Allow-Credentials`` en double (« true, true »
|
||||
≠ « true » pour le contrôle CORS du navigateur → fetch rejeté →
|
||||
« injoignable (CORS ou réseau) » dans la console, alors que TikTak et
|
||||
Kanbante, sans ce cumul, répondent avec des en-têtes uniques) et,
|
||||
pour une origine sœur déjà dans la allowlist globale, ``Allow-Origin``
|
||||
en double (rejet certain). Le correctif : le middleware de recherche
|
||||
REMPLACE les en-têtes CORS existants au lieu de les empiler (même
|
||||
patron que app/recherche_cors.py de la console).
|
||||
|
||||
Ces tests pilotent le middleware en ASGI pur, avec une application
|
||||
interne qui imite le CORSMiddleware global (un en-tête
|
||||
Allow-Credentials déjà posé, et, pour une sœur, Allow-Origin aussi).
|
||||
``sister_origins`` est figé sur les listes officielles (les fonctions
|
||||
pures sont déjà couvertes par tests/test_recherche.py) pour garder le
|
||||
test hermétique (aucune résolution draco_urls, aucune base).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.middleware import recherche_cors as rc
|
||||
|
||||
ORIGINES = rc.PROD_ORIGINS | rc.DEV_ORIGINS | rc.AGENT_ORIGINS
|
||||
|
||||
|
||||
def _lancer(middleware, scope):
|
||||
"""Exécute le middleware ASGI et retourne les messages envoyés."""
|
||||
recus = []
|
||||
|
||||
async def recevoir():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def envoyer(message):
|
||||
recus.append(message)
|
||||
|
||||
asyncio.run(middleware(scope, recevoir, envoyer))
|
||||
return recus
|
||||
|
||||
|
||||
def _application_interne(entetes):
|
||||
async def appli(scope, recevoir, envoyer):
|
||||
await envoyer({"type": "http.response.start", "status": 401,
|
||||
"headers": list(entetes)})
|
||||
await envoyer({"type": "http.response.body", "body": b"{}"})
|
||||
return appli
|
||||
|
||||
|
||||
def _scope(methode, origine):
|
||||
return {"type": "http", "method": methode, "path": "/api/search",
|
||||
"headers": [(b"origin", origine.encode("latin-1")),
|
||||
(b"host", b"flowdeck-dev.dracodev.net")]}
|
||||
|
||||
|
||||
def _compter(entetes, nom):
|
||||
return sum(1 for k, _v in entetes if k == nom.encode("latin-1"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _origines_figees(monkeypatch):
|
||||
monkeypatch.setattr(rc, "sister_origins", lambda *a, **k: ORIGINES)
|
||||
|
||||
|
||||
def test_get_agent_dev_un_seul_jeu_d_entetes():
|
||||
"""Cas du défaut : globale (ACAC seul) + recherche → un seul jeu."""
|
||||
interne = _application_interne([
|
||||
(b"content-type", b"application/json"),
|
||||
(b"access-control-allow-credentials", b"true")])
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||
_scope("GET", "https://agent-dev.dracodev.net"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||
assert _compter(entetes, "vary") == 1
|
||||
assert (b"access-control-allow-origin",
|
||||
b"https://agent-dev.dracodev.net") in entetes
|
||||
assert (b"content-type", b"application/json") in entetes
|
||||
assert recus[0]["status"] == 401 # le statut traverse inchangé
|
||||
|
||||
|
||||
def test_get_origine_soeur_pas_d_allow_origin_double():
|
||||
"""Sœur déjà dans la allowlist globale : pas de doublon non plus."""
|
||||
interne = _application_interne([
|
||||
(b"access-control-allow-origin", b"https://og-dev.dracodev.net"),
|
||||
(b"access-control-allow-credentials", b"true"),
|
||||
(b"vary", b"Origin")])
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||
_scope("GET", "https://og-dev.dracodev.net"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||
assert _compter(entetes, "vary") == 1
|
||||
|
||||
|
||||
def test_origine_non_autorisee_traverse_sans_modification():
|
||||
"""Une origine refusée ne reçoit aucun Allow-Origin ajouté."""
|
||||
interne = _application_interne([
|
||||
(b"access-control-allow-credentials", b"true")])
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||
_scope("GET", "https://example.com"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert _compter(entetes, "access-control-allow-origin") == 0
|
||||
assert entetes == [(b"access-control-allow-credentials", b"true")]
|
||||
|
||||
|
||||
def test_preflight_agent_dev_toujours_unique():
|
||||
"""Le preflight (court-circuit avant l'interne) reste propre."""
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(_application_interne([])),
|
||||
_scope("OPTIONS", "https://agent-dev.dracodev.net"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert recus[0]["status"] == 204
|
||||
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Recherche fédérée « Rechercher partout » (fusion #111) — garde-fous
|
||||
côté Flowdeck : l'API /api/search et les origines sœurs sont couvertes
|
||||
par les suites Node et les tests CORS ; ici, la CSP des pages, qui
|
||||
conditionne les appels cross-origin de la page /recherche."""
|
||||
|
||||
|
||||
def test_csp_connect_src_contient_shaarit(client):
|
||||
"""La CSP des pages doit autoriser l'origine de ShaarIt en
|
||||
connect-src, sinon le navigateur bloque l'appel de la page
|
||||
/recherche avant même le CORS et la section ShaarIt affiche
|
||||
« Injoignable depuis cet hôte » (retour d'usage du 2026-11-04 :
|
||||
la liste des sœurs du connect-src, écrite pour les Alertes
|
||||
#69, n'avait pas été étendue à ShaarIt)."""
|
||||
r = client.get("/")
|
||||
csp = r.headers.get("content-security-policy", "")
|
||||
assert "connect-src" in csp
|
||||
assert "https://shaarit.dracodev.net" in csp, csp
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Page /recherche Flowdeck : onglets, base console, anti-cache (defaut 6).
|
||||
|
||||
Audit prealable (main 7.84.4, tag v7.84.4 = 984e92b) : la route
|
||||
(app/routers/search.py:51), le gabarit (app/templates/recherche.html),
|
||||
le JS (static/js/recherche.js) et le CSS (static/css/recherche.css)
|
||||
contenaient DEJA la page a onglets des PR #33/#36 — aucune route
|
||||
fantome, aucun bloc `scripts` manquant dans base.html:869, aucune
|
||||
regle CSP ne bloquant le script (script-src 'self'). Le symptome
|
||||
deploie (« pas d'onglets, resultats Flowdeck seuls ») ne s'explique
|
||||
donc PAS par un mauvais gabarit dans main, mais par deux causes
|
||||
reelles, corrigees ici :
|
||||
|
||||
1. Assets recherche servis CACHE-FIRST par le service worker
|
||||
(static/sw.js) SANS version (?v= absent) sous un CACHE_NAME
|
||||
inchange depuis A20 ('flowdeck-v8') : un navigateur ayant visite
|
||||
Flowdeck avant #33 gardait le recherche.js pre-#33 (sans onglets,
|
||||
sans compteurs, sans logique console, « Tout voir » naviguant
|
||||
ailleurs) et le recherche.css pre-#33 (sans .rp-tabs) — la page
|
||||
paraissait donc « non corrigee » malgre les deplois 7.84.3/7.84.4,
|
||||
et le repli networkFirst du HTML vers le cache couvrait aussi le
|
||||
cas reseau lent. Correctif : liens versionnes (?v=asset_version,
|
||||
comme tous les autres assets de base.html) + bump CACHE_NAME
|
||||
(purge a l'activation du nouveau worker).
|
||||
2. Flowdeck etait le seul hote a ne pas rendre data-base-console
|
||||
(son propre commentaire l'admettait) : la base console de l'onglet
|
||||
Console Spark etait devine (derivation -dev / href d'onglet de
|
||||
barre) au lieu d'etre explicite. Correctif : la route expose
|
||||
`bases.agent` et le gabarit le rend en data-base-console.
|
||||
|
||||
La logique d'onglets elle-meme (focus, compteurs, « Tout voir » sur
|
||||
place, etats par application, liens -dev, console sur
|
||||
GET /api/v1/recherche) est deja couverte par tests/recherche.test.js
|
||||
(suite Node, lancee en sandbox) ; ce fichier-ci verrouille le
|
||||
branchement gabarit/route/service worker, sans serveur ni base.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
ONGLETS_ATTENDUS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante",
|
||||
"shaarit", "console"]
|
||||
|
||||
|
||||
def test_gabarit_recherche_onglets_complets():
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
trouves = re.findall(r'data-rp-tab="([^"]+)"', gabarit)
|
||||
assert trouves == ONGLETS_ATTENDUS, trouves
|
||||
for section in ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit",
|
||||
"console"):
|
||||
assert f'data-rp-section="{section}"' in gabarit
|
||||
assert 'id="rp-q"' in gabarit and "autofocus" in gabarit
|
||||
|
||||
|
||||
def test_gabarit_recherche_base_console_explicite():
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
assert 'data-base-console="{{ bases.agent }}"' in gabarit
|
||||
|
||||
|
||||
def test_gabarit_recherche_assets_versionnes():
|
||||
"""Sans ?v=, le SW cache-first ressert les copies pre-#33."""
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
assert "/static/css/recherche.css?v={{ asset_version }}" in gabarit
|
||||
assert "/static/js/recherche.js?v={{ asset_version }}" in gabarit
|
||||
|
||||
|
||||
def test_route_recherche_expose_base_agent():
|
||||
route = (ROOT / "app/routers/search.py").read_text()
|
||||
assert '"agent"' in route or "'agent'" in route
|
||||
assert "bases.agent" in (ROOT / "app/templates/recherche.html").read_text()
|
||||
|
||||
|
||||
def test_service_worker_purge_anciens_caches():
|
||||
"""Le bump de CACHE_NAME purge les caches pre-#33 a l'activation."""
|
||||
sw = (ROOT / "static/sw.js").read_text()
|
||||
assert "const CACHE_NAME = 'flowdeck-v9'" in sw
|
||||
assert "flowdeck-v8" not in sw
|
||||
assert "cache-first" in sw or "cacheFirst" in sw
|
||||
|
||||
|
||||
def test_recherche_js_branche_console_v1():
|
||||
"""Rappel : l'onglet console appelle GET /api/v1/recherche (cf. #36)."""
|
||||
js = (ROOT / "static/js/recherche.js").read_text()
|
||||
assert 'CONSOLE_SEARCH_PATH = "api/v1/recherche"' in js
|
||||
assert "CONSOLE_SEARCH_LIMIT = 20" in js
|
||||