Compare commits

...
Author SHA1 Message Date
Spark cf2e39e15f Flowdeck 7.85.6 — porte centrale unique (phase 2) : écran réduit à l'entrée ObsiGate SSO + porte de secours administrateur par variables d'environnement, désactivée par défaut
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-11-07 04:52:51 +00:00
Spark 31fe0c181a tests: ouvrir explicitement l'inscription dans les fixtures locales de test_app et test_v66_agent_api (fusion #146)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-11-07 04:52:51 +00:00
Spark 931e4d740b Flowdeck 7.85.5 — inscription locale fermée par défaut (fusion #146) + échec de connexion nommant la voie ObsiGate SSO (fusion #144)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / lint (pull_request) Failing after 3s
FlowDeck CI / test (pull_request) Failing after 35m42s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / test (push) Failing after 3h10m22s
FlowDeck CI / docker (push) Skipped
2026-11-07 04:29:23 +00:00
bruno 8915c7ddd1 Merge pull request 'Flowdeck 7.85.4 — barre v0.6.0 re-vendangée : entrée « Administration » (fusion #142)' (#56) from menu-compte-admin-v060 into main
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-06 15:28:44 -05:00
Spark 0c3b6c522f Flowdeck 7.85.4 — barre v0.6.0 re-vendangée : entrée Administration (sans drapeau, rôle global inconnu) (fusion #142)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-11-06 20:06:31 +00:00
bruno 6ce408059c Merge pull request 'Flowdeck 7.85.3 — Déconnexion du menu compte branchée sur la déconnexion globale (fusion #141)' (#55) from fix/deconnexion-globale-7-85-3 into main
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 31m16s
FlowDeck CI / docker (push) Skipped
2026-11-06 14:27:08 -05:00
Spark fcc6cdf830 fix: brancher la déconnexion du menu compte sur la déconnexion globale (fusion #141) — 7.85.3
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 10s
2026-11-06 19:01:13 +00:00
bruno e6f5cff870 Merge pull request 'Flowdeck 7.85.2 — HUB-19 : propagation des rôles du Hub (registre D3(a))' (#53) from hub19-roles-flowdeck into main
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-06 11:32:10 -05:00
Spark bd5357f589 Flowdeck 7.85.2 — HUB-19 : propagation des rôles du Hub (registre D3(a))
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 12s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 14s
2026-11-06 15:36:56 +00:00
bruno 5203b6288e Merge pull request 'Flowdeck 7.85.1 — HUB-18 : thème commun, barre v0.5.0 + initialisation thème Hub (fusion #132)' (#52) from hub18-theme-commun into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-06 09:47:54 -05:00
Spark 50d7f337da Flowdeck 7.85.1 — HUB-18 : thème commun, barre v0.5.0 + initialisation thème Hub (fusion #132)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
2026-11-06 14:47:02 +00:00
bruno 0eec63a5ca Merge pull request 'Flowdeck 7.85.0 — HUB-15 : provisionnement JIT des comptes draco (fusion #129)' (#51) from hub15-jit into main
FlowDeck CI / lint (push) Failing after 13s
FlowDeck CI / test (push) Failing after 30m40s
FlowDeck CI / docker (push) Skipped
2026-11-06 09:44:20 -05:00
Spark 296d9cfa25 Flowdeck 7.85.0 — HUB-15 : provisionnement JIT des comptes draco (fusion #129)
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 13s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 12s
Généralisation de maybe_link : à la première connexion draco, si
aucun compte local ne correspond au sub (ni lien draco_links, ni
repli mono-utilisateur), le pendant local est créé depuis les
claims (sub, name) et lié dans la même transaction. Rôle par
défaut sûr : utilisateur standard (is_admin=0), auth_method
'draco', password_hash NULL (aucune connexion locale par mot de
passe pour ce compte ; inchangée pour les autres). Garde pierre
tombale : une ligne draco_links existante interdit toute création.
Connexion locale et OAuth Gitea/GitHub intacts.

Tests : test_fusion_draco.py étendu (création JIT, pierre
tombale) ; 9/9 sur le fichier, balayage auth 226 réussis
(3 échecs préexistants sur main vierge, réseau).
2026-11-06 14:03:23 +00:00
bruno be3e4e5f69 Merge pull request 'Flowdeck 7.84.15 — barre v0.4.1 : correctif des icônes invisibles du menu compte (#125)' (#50) from hub12-barre-v041 into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-05 02:09:32 -05:00
Spark 54bd43e9f2 Flowdeck 7.84.15 : barre v0.4.1 — menu compte resserré à 232 px, re-vendange verbatim (fusion #125)
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 2s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (push) Failing after 13s
2026-11-05 07:08:06 +00:00
Spark 9068e1572f Flowdeck 7.84.15 : barre v0.4.1 — correctif des icônes invisibles du menu compte, vendange verbatim (fusion #125) + garde + version/changelog
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-05 05:52:32 +00:00
bruno 23bcea4b56 Merge pull request '7.84.14 — barre v0.4.0 : icônes monoline dans le menu compte (HUB-11)' (#49) from hub11-barre-v040 into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-11-05 00:33:58 -05:00
Spark e7c65b6f49 Flowdeck 7.84.14 : garde v0.4.0 corrigée (clés JS quotées dans MENU_ICONES)
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 14s
FlowDeck CI / lint (pull_request) Failing after 5s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
2026-11-05 05:05:32 +00:00
Spark 77b4ee29e6 Flowdeck 7.84.14 : barre v0.4.0 — icônes monoline du menu compte, vendange verbatim (HUB-11) + garde + version/changelog 2026-11-05 05:05:11 +00:00
bruno 95ba369e04 Merge pull request '7.84.13 — barre v0.3.0 : liens Hub du menu compte (HUB-1)' (#48) from hub1-barre-v030 into main
FlowDeck CI / test (push) Failing after 29m22s
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
2026-11-04 19:49:05 -05:00
Spark a75a477fea Flowdeck 7.84.13 : version et changelog (barre v0.3.0, HUB-1)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 15s
FlowDeck CI / test (push) Failing after 27m1s
FlowDeck CI / docker (push) Skipped
2026-11-04 21:45:14 +00:00
Spark c8beccaa2e Flowdeck 7.84.13 : tests Hub (résolution draco_urls, marqueurs barre v0.3.0) 2026-11-04 21:45:14 +00:00
Spark 08e912d56f Flowdeck 7.84.13 : barre v0.3.0 — liens Hub du menu compte, URL Hub résolue comme les sœurs (draco_urls) 2026-11-04 21:45:14 +00:00
bruno 0464f6156e Merge pull request '7.84.12 — correctif : ShaarIt joignable depuis Flowdeck (CSP connect-src)' (#47) from fix/recherche-shaarit-csp into main
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 30m28s
FlowDeck CI / docker (push) Skipped
2026-11-04 13:43:41 -05:00
Spark 8bba621223 Correctif : origine ShaarIt en connect-src (CSP) — la recherche fédérée passe
FlowDeck CI / lint (push) Failing after 13s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 12s
FlowDeck CI / test (push) Failing after 19m33s
FlowDeck CI / docker (push) Skipped
La liste des sœurs du connect-src (Fusion #69, panneau Alertes)
n'avait pas été étendue à ShaarIt lors du branchement de la
recherche fédérée (#111) : le navigateur bloquait l'appel de la
page /recherche vers l'API ShaarIt avant même le CORS, et la
section affichait « Injoignable depuis cet hôte (CORS ou réseau) »
alors que le CORS de ShaarIt était correct. Test CSP ajouté.
2026-11-04 18:42:57 +00:00
bruno da5000b727 Merge pull request '7.84.11 — recherche fédérée : ShaarIt, 5e source de « Rechercher partout »' (#46) from recherche-shaarit into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-11-04 12:11:07 -05:00
Spark 3539611aa7 7.84.11 — recherche fédérée : ShaarIt, 5e source de « Rechercher partout »
FlowDeck CI / lint (push) Failing after 9s
FlowDeck CI / lint (pull_request) Failing after 4s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / test (push) Failing after 29m57s
FlowDeck CI / docker (push) Skipped
- static/js/recherche.js re-vendangé depuis commons v0.2.1 (source
  canonique ui/recherche/) : source ShaarIt (bases, onglet,
  normaliseur — lien profond {base}shaare/<shorturl>).
- Gabarit + route /recherche : data-base-shaarit (bases.shaarit),
  onglet et section « ShaarIt — bookmarks ».
- recherche_cors : origines ShaarIt prod/dev + clé sœur shaarit.
- Tests : recherche.test.js 83 assertions (5 sections, normaliseur
  ShaarIt), onglets Python et allowlist (12 origines) à jour.
2026-11-04 17:10:30 +00:00
bruno 1f82c2653a Merge pull request 'Flowdeck 7.84.10 : barre v0.2.0 — onglet ShaarIt + garde data-theme' (#45) from barre-v020-7-84-10 into main
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 13s
2026-11-04 11:16:39 -05:00
bruno e24b2a9495 Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (CHANGELOG.md)
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 10s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 14s
2026-11-04 03:44:01 +00:00
bruno 73cd12d9de Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (VERSION) 2026-11-04 03:44:01 +00:00
bruno d113ce8c5f Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (tests/test_draco_urls_devprod.py) 2026-11-04 03:44:01 +00:00
bruno 4b1888eebb Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (tests/test_draco_bar_texte.py) 2026-11-04 03:44:01 +00:00
bruno 1e733f3662 Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (.env.example) 2026-11-04 03:44:01 +00:00
bruno 6eccacd982 Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (app/config.py) 2026-11-04 03:44:01 +00:00
bruno 5508d21e0a Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (app/services/draco_urls.py) 2026-11-04 03:44:01 +00:00
bruno 49bc6ca54a Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (app/templates/_draco_bar.html) 2026-11-04 03:44:01 +00:00
bruno b58172dcdd Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (static/img/logos/shaarit.svg) 2026-11-04 03:44:01 +00:00
bruno 4fdfc0c385 Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (static/js/vendor/draco-barre.js) 2026-11-04 03:44:01 +00:00
bruno e2886ccc95 Flowdeck 7.84.10 : barre v0.2.0, onglet ShaarIt + garde data-theme (static/css/draco-barre.css) 2026-11-04 03:44:01 +00:00
bruno 427df2ca92 Merge pull request 'Flowdeck 7.84.9 : barre v0.1.11 — marque monochrome, anneau rouge avatar' (#44) from barre-v0111-7-84-9 into main
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-29 23:09:35 -04:00
bruno 1a717ede16 Flowdeck 7.84.9 : étoile Spark monochrome pour la marque
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
2026-10-29 23:09:19 -04:00
bruno d3abeb197a Flowdeck 7.84.9 : barre v0.1.11, marque monochrome, anneau avatar (CHANGELOG.md)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-29 23:09:17 -04:00
bruno dee5fb5752 Flowdeck 7.84.9 : barre v0.1.11, marque monochrome, anneau avatar (VERSION)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-29 23:09:15 -04:00
bruno 9de1166661 Flowdeck 7.84.9 : barre v0.1.11, marque monochrome, anneau avatar (static/css/draco-barre.css)
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m54s
2026-10-29 23:09:13 -04:00
bruno c0add7b6de Flowdeck 7.84.9 : barre v0.1.11, marque monochrome, anneau avatar (app/templates/_draco_bar.html)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-29 23:09:12 -04:00
bruno d29986e51d Merge pull request 'Flowdeck 7.84.8 : barre v0.1.10, rouge Spark, avatar flottant, Alertes cloche' (#43) from barre-rouge-spark-7-84-8 into main
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m52s
2026-10-29 22:55:18 -04:00
bruno cd8969a3a2 Flowdeck 7.84.8 : avatar Spark flottant (commons v0.1.10)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-29 22:55:00 -04:00
bruno e8cbd8e0f3 Flowdeck 7.84.8 : barre v0.1.10, rouge Spark, avatar flottant, Alertes cloche (CHANGELOG.md)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-29 22:54:57 -04:00
bruno d40044e0c4 Flowdeck 7.84.8 : barre v0.1.10, rouge Spark, avatar flottant, Alertes cloche (VERSION)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-29 22:54:56 -04:00
bruno 41865c702a Flowdeck 7.84.8 : barre v0.1.10, rouge Spark, avatar flottant, Alertes cloche (static/css/draco-barre.css)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-29 22:54:54 -04:00
bruno 56b8ff2072 Flowdeck 7.84.8 : barre v0.1.10, rouge Spark, avatar flottant, Alertes cloche (app/templates/_draco_bar.html)
FlowDeck CI / lint (push) Failing after 4m22s
FlowDeck CI / test (push) Failing after 3h8m21s
FlowDeck CI / docker (push) Skipped
2026-10-29 22:54:53 -04:00
42 changed files with 2558 additions and 94 deletions
+1
View File
@@ -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=
+108
View File
@@ -1,3 +1,111 @@
## v7.85.6 (2026-11-06) — Porte centrale unique (phase 2 du programme « compte partout »), désactivée par défaut
> Branche basée sur la 7.85.5 (phase 1, #146/#144) — PR distincte, à fusionner après elle.
- **Nouveau réglage « porte centrale »** (`porte_centrale_active` dans `app/services/reglages_acces.py`, même patron que le réglage d'inscription de la phase 1, **distinct** de lui, **désactivé par défaut à la livraison** ; activable/désactivable par un administrateur via `GET`/`PUT /api/admin/reglages-acces` ou la variable `PORTE_CENTRALE_ACTIVE`).
- **Mode actif** : l'écran de connexion ne présente que l'entrée centrale — le bouton « Login with ObsiGate (SSO) », bien visible, et le lien vers le Hub Dracodev où les comptes se créent (URL dérivée de l'hôte). `POST /auth/local-login` refuse les identifiants locaux ordinaires (403, message nommant la voie SSO), **y compris pour les comptes locaux historiques** ; l'inscription est également fermée dans ce mode. Les sessions déjà établies ne sont pas touchées, et la voie draco (SSO/JIT) reste la porte normale.
- **Porte de secours administrateur** : UN compte administrateur local désigné peut encore s'authentifier localement, uniquement par le chemin discret `GET /auth/login?provider=local&secours=1` (le formulaire y marque ses envois `secours` ; jamais proposé sur l'écran principal). Ses identifiants sont posés par les variables d'environnement `FLOWDECK_SECOURS_ADMIN_LOGIN` et `FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE` (comparaison à temps constant ; le compte local correspondant doit exister, être actif et administrateur ; aucun secret en dur, aucune valeur par défaut — sans variables, la porte est fermée).
- **Mode inactif** : comportement actuel intégralement préservé (formulaire local, inscription selon le réglage de la phase 1).
- **Tests** : `tests/test_porte_centrale.py` (nouveau, 15 tests) — défaut désactivé vérifié dans le code, réglage distinct de celui d'inscription, refus du compte ordinaire et du compte historique en mode actif, écran réduit à l'entrée centrale (aucun formulaire, aucun « secours »), inscription fermée en mode actif, session établie préservée, secours : écran discret, connexion admin réussie, mauvais mot de passe refusé, absence de drapeau refusée, compte non-admin désigné refusé, absence de variables = porte fermée ; mode inactif = comportement actuel ; aller-retour de l'API d'administration.
## v7.85.5 (2026-11-06) — Inscription locale fermée par défaut + échec de connexion qui nomme la voie SSO (fusion #146, #144)
- **#146 — l'inscription publique locale n'est plus ouverte par défaut** : nouveau réglage d'administration « inscription locale ouverte » (`app/services/reglages_acces.py`, patron `llm_config`/`sso_config` : ligne unique en base qui prime sur la variable `INSCRIPTION_LOCALE_OUVERTE`, défaut **fermée** — décision de Bruno : les comptes naissent dans le Hub Dracodev). Réversible à tout moment par un administrateur via `GET`/`PUT /api/admin/reglages-acces`.
- **Quand elle est fermée** : `POST /auth/register` répond 403 et `GET /auth/register` affiche une page de refus propre en français — les comptes se créent dans le Hub Dracodev, avec le lien du Hub dérivé de l'hôte (`draco_urls.hub_url()`, comme les autres liens de la suite) ; l'onglet Register disparaît de l'écran de connexion (avis nommant le Hub à la place) et les boutons « Get started » de la page d'accueil cèdent la place au lien Hub. **La connexion des comptes existants, locaux comme draco, n'est jamais affectée.**
- **#144 (part Flowdeck)** : l'échec du formulaire de connexion locale (« Invalid credentials », 401) suggère désormais la voie centrale : « Si votre compte a été créé dans le Hub Dracodev, connectez-vous avec le bouton « Login with ObsiGate (SSO) »… » — le bouton existant, dont le libellé est repris tel quel. Aucune logique d'authentification ne change.
- **Tests** : `tests/test_reglages_acces.py` (nouveau, 12 tests) — fermée par défaut (défaut du code vérifié), POST refusé 403 + message + aucun compte créé, page de refus, onglet Register absent, ouverte = inscription d'aujourd'hui, connexion des comptes existants (local et draco) inchangée ouvert/fermé, échec local nommant ObsiGate (SSO), aller-retour de l'API d'administration et refus aux anonymes. Le harnais de test ouvre explicitement l'inscription pour les suites historiques qui créent leurs comptes par `/auth/register` (conftest et les fixtures locales des suites historiques, commenté).
## v7.85.4 (2026-11-06) — Barre commune v0.6.0 re-vendangée : entrée « Administration » (fusion #142)
- **Re-vendange verbatim** de la barre commons v0.6.0 (PR dracodev-commons #14) : `static/js/vendor/draco-barre.js` + `static/css/draco-barre.css`. Le menu compte gagne l'entrée **Administration** → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), à **visibilité conditionnelle** (affirmée par l'hôte via `cfg.admin` / `DRACO_BAR.setAdmin`, jamais déduite par la barre).
- **Pas de drapeau chez Flowdeck** : Flowdeck ne connaît que le rôle du registre du Hub pour sa propre application (consulté au pont draco avant le JIT) — pas le rôle global de suite (rôle du compte chez ObsiGate, la définition de la garde du Hub). À défaut de certitude, le contrat v0.6.0 impose de ne pas affirmer : l'entrée reste masquée dans Flowdeck (elle apparaît chez ObsiGate et dans la console, qui peuvent l'établir). Commentaire posé dans le gabarit `_draco_bar.html`.
- **Test** : `tests/test_draco_bar_texte.py::test_barre_v060_entree_administration_vendangee` (marqueurs de la vendange dans les assets servis).
## v7.85.3 (2026-11-06) — Déconnexion du menu compte branchée (fusion #141)
- **Défaut corrigé** : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien — la barre relaie l'action `deconnexion` à l'hôte et le `onAction` du gabarit (`app/templates/_draco_bar.html`) n'avait aucune branche pour elle (même défaut que la console Spark, l'hôte du signalement de Bruno).
- **Branchement** : nouvelle branche `deconnexion` — destruction d'abord de la session locale Flowdeck (`fetch GET /auth/logout`, le même point de sortie que les liens « Log out » existants), puis navigation vers la déconnexion globale de l'émettrice (`GET {draco_urls().obsigate}/sso/logout?return_to={origine}/`, nouvelle route ObsiGate 2.86.0, PR sœur) : le `jti` draco y est révoqué et le cookie effacé sur le domaine parent. Après le parcours, la session de la suite entière et la session locale Flowdeck sont terminées ; le retour se fait sur la racine Flowdeck (page de connexion).
- **Test** : `tests/test_draco_bar_texte.py::test_barre_deconnexion_branchee_globale` (marqueurs du branchement dans le gabarit). Périmètre draco/barre comparé à main vierge dans le même environnement : fonds identique (2 échecs `test_draco_urls_devprod` et 17 erreurs de dépendances absentes de l'environnement local, préexistants), +1 test passant.
## 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é.
+1 -1
View File
@@ -1 +1 @@
7.84.7
7.85.6
+23
View File
@@ -46,6 +46,25 @@ class Settings(BaseSettings):
log_level: str = "INFO"
default_lang: str = "fr"
# Réglages d'accès (fusion #146) — défauts repris par
# `app/services/reglages_acces.py` ; la ligne enregistrée en base
# par un administrateur prime sur ces valeurs.
# Inscription publique locale : fermée par défaut (décision de
# Bruno — les comptes naissent dans le Hub Dracodev).
inscription_locale_ouverte: bool = False
# Porte centrale unique (phase 2 du programme « compte partout ») :
# désactivée par défaut à la livraison ; même patron de réglage
# (la ligne en base prime). Activée, seuls la voie ObsiGate (SSO)
# et la porte de secours administrateur ouvrent une session.
porte_centrale_active: bool = False
# Porte de secours administrateur (porte centrale) — repli des
# variables d'environnement FLOWDECK_SECOURS_ADMIN_LOGIN /
# FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE, lues en priorité par
# `app/services/reglages_acces.py`. Aucune valeur par défaut :
# sans variables posées, la porte de secours est fermée.
secours_admin_login: str = ""
secours_admin_mot_de_passe: str = ""
# Rate limiting
rate_limit_enabled: bool = True
rate_limit_requests: int = 60 # per minute
@@ -156,6 +175,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 +189,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.
+1 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.84.3"
# 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()
+3 -1
View File
@@ -43,6 +43,7 @@ PROD_ORIGINS = frozenset({
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://shaarit.dracodev.net",
})
DEV_ORIGINS = frozenset({
@@ -50,6 +51,7 @@ DEV_ORIGINS = frozenset({
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://shaarit-dev.dracodev.net",
})
AGENT_ORIGINS = frozenset({
@@ -59,7 +61,7 @@ AGENT_ORIGINS = frozenset({
# Clés de draco_urls.toutes() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
def is_dev_host(hostname: str | None) -> bool:
+8 -1
View File
@@ -178,10 +178,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# 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")
for k in ("obsigate", "flowdeck", "tiktak", "kanbante",
"shaarit")
} - {""}))
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
nonce=nonce, host=host, agent=agent, sisters=sisters
+42
View File
@@ -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
@@ -136,6 +146,38 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
return {"status": "ok"}
# ── Réglages d'accès (fusion #146) ──
@router.get("/reglages-acces")
def get_reglages_acces(_admin=Depends(admin_required)):
"""Réglages d'accès effectifs de l'instance (administrateurs)."""
from app.services import reglages_acces
return reglages_acces.get_reglages_acces()
@router.put("/reglages-acces")
def put_reglages_acces(_admin=Depends(admin_required), body: dict = Body(default={})):
"""Modifie les réglages d'accès — réversible à tout moment.
Corps accepté : ``{"inscription_locale_ouverte": true|false,
"porte_centrale_active": true|false}`` (chaque clé optionnelle).
Inscription fermée (défaut) : aucun compte local ne se crée sur
l'instance, les comptes naissent dans le Hub Dracodev.
Porte centrale (défaut : inactive) : activée, seuls la voie
ObsiGate (SSO) et la porte de secours administrateur ouvrent
une session.
"""
from app.services import reglages_acces
kwargs = {}
if "inscription_locale_ouverte" in body:
kwargs["inscription_locale_ouverte"] = bool(
body["inscription_locale_ouverte"])
if "porte_centrale_active" in body:
kwargs["porte_centrale_active"] = bool(body["porte_centrale_active"])
if kwargs:
return reglages_acces.set_reglages_acces(**kwargs)
return reglages_acces.get_reglages_acces()
# ── Stats ──
@router.get("/stats")
def user_stats(_admin=Depends(admin_required)):
+8
View File
@@ -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
+8
View File
@@ -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 ""
+217 -5
View File
@@ -51,6 +51,125 @@ def _sso_login_url() -> str:
f"?return_to={quote(retour, safe='')}")
# Échec de connexion locale (fusion #144) : le message suggère la voie
# centrale — un compte créé dans le Hub Dracodev n'a pas de mot de
# passe local, son entrée est le bouton « Login with ObsiGate (SSO) »
# du même écran (libellé repris tel quel).
MESSAGE_ECHEC_CONNEXION_LOCALE = (
"Identifiants invalides. Si votre compte a été créé dans le Hub "
"Dracodev, connectez-vous avec le bouton « Login with ObsiGate "
"(SSO) » de cet écran : les comptes de la suite n'ont pas de mot "
"de passe local."
)
def _page_inscription_fermee() -> str:
"""Page de refus propre du parcours d'inscription fermé (#146)."""
from app.services import draco_urls, reglages_acces
hub = draco_urls.hub_url()
message = reglages_acces.message_inscription_fermee()
return f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — Inscription fermée</title>
<style>
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}}
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:420px;}}
.box h1{{font-size:22px;margin:0 0 12px;}}
.box p{{color:rgba(255,255,255,.6);font-size:14px;line-height:1.6;margin:0 0 16px;}}
.box a{{color:#2383E2;}}
</style>
</head>
<body>
<div class="box">
<h1>Inscription fermée</h1>
<p>{message}</p>
<p><a href="{hub}">Ouvrir le Hub Dracodev</a> ·
<a href="/auth/login?provider=local">Retour à la connexion</a></p>
</div>
</body>
</html>"""
def _page_porte_centrale() -> str:
"""Écran de connexion en mode porte centrale (phase 2).
Ne présente **que** l'entrée centrale : le bouton ObsiGate (SSO),
bien visible. Aucun formulaire local, aucun onglet Register —
la porte de secours administrateur passe par le chemin discret
``?secours=1``, jamais proposé ici.
"""
from app.services import draco_urls
hub = draco_urls.hub_url()
return """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — Connexion</title>
<style>
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
.box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
.box h1{font-size:24px;margin:0 0 8px;}
.box p{color:rgba(255,255,255,.55);font-size:14px;line-height:1.6;margin:0 0 20px;}
.box a{color:#2383E2;}
.sso{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:14px;border-radius:8px;font-size:15px;font-weight:500;cursor:pointer;border:1px solid rgba(35,131,226,.6);background:#2383E2;color:#fff;text-decoration:none;box-sizing:border-box;}
.sso:hover{background:#2C8CEB;}
</style>
</head>
<body>
<div class="box">
<h1>FlowDeck</h1>
<p>Cette instance n'accepte que la connexion centrale de la suite
Dracodev. Les comptes se créent dans le
<a href="__HUB_URL__">Hub Dracodev</a>.</p>
<a class="sso" href="__SSO_LOGIN_URL__">🧭 Login with ObsiGate (SSO)</a>
</div>
</body>
</html>""".replace("__HUB_URL__", hub)
def _rendre_page_connexion(html: str, secours: bool = False) -> str:
"""Rend la page de connexion en tenant compte des réglages d'accès.
Porte centrale active (phase 2) : seul l'écran central est rendu
— sauf par le chemin discret de secours (``secours=True``), qui
rend le formulaire local en marquant ses envois ``secours``.
Inscription fermée (#146) : l'onglet Register disparaît et un
avis nomme le Hub Dracodev (lien dérivé de l'hôte, comme les
autres liens de la suite). Sinon : page intégralement inchangée.
"""
from app.services import draco_urls, reglages_acces
if reglages_acces.porte_centrale_active():
if not secours:
return _with_nonce(_page_porte_centrale())
html = html.replace(
"const body={email,password};",
"const body={email,password,secours:true};")
return _with_nonce(html)
if reglages_acces.inscription_locale_ouverte():
return _with_nonce(html)
html = html.replace(
"""<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>""",
"",
)
avis = (
'<div class="success" style="display:block;'
'background:rgba(35,131,226,.15);color:#7db8f0">'
"Pas encore de compte ? Les comptes se créent dans le "
f'<a href="{draco_urls.hub_url()}" style="color:#7db8f0">'
"Hub Dracodev</a>, puis se connectent ici avec le bouton "
"« Login with ObsiGate (SSO) ».</div>"
)
html = html.replace('<div id="expired-msg"', avis + '<div id="expired-msg"', 1)
return _with_nonce(html)
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
@@ -180,7 +299,18 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register")
def register_page(request: Request):
"""Show the registration page (local login page with register tab active)."""
"""Show the registration page (local login page with register tab active).
Inscription fermée (#146, défaut) : refus propre — page en
français nommant le Hub Dracodev, là où les comptes se créent.
"""
from app.services import reglages_acces
if reglages_acces.porte_centrale_active():
return HTMLResponse(_with_nonce(_page_porte_centrale()),
status_code=403)
if not reglages_acces.inscription_locale_ouverte():
return HTMLResponse(_page_inscription_fermee(), status_code=403)
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"'
@@ -205,7 +335,13 @@ def login(request: Request, provider: str = Query("gitea")):
# Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse
if provider == "local":
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# Chemin discret de la porte de secours (phase 2) :
# ?secours=1 rend le formulaire local même en mode porte
# centrale — jamais lié depuis l'écran principal.
secours = request.query_params.get("secours") == "1"
return HTMLResponse(
_rendre_page_connexion(LOCAL_LOGIN_HTML, secours=secours),
status_code=200)
# OAuth flow — check if provider is configured
from app.auth.providers import get_provider
@@ -259,6 +395,25 @@ def register(request: Request, body: dict = Body(default={})):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
# Inscription locale fermée (fusion #146, défaut) : les comptes
# naissent dans le Hub Dracodev — aucun compte local ne se crée
# ici. La connexion des comptes existants n'est pas touchée.
from app.services import draco_urls, reglages_acces
if reglages_acces.porte_centrale_active():
from fastapi.responses import JSONResponse
return JSONResponse(
{"error": reglages_acces.message_porte_centrale(),
"hub_url": draco_urls.hub_url()},
status_code=403,
)
if not reglages_acces.inscription_locale_ouverte():
from fastapi.responses import JSONResponse
return JSONResponse(
{"error": reglages_acces.message_inscription_fermee(),
"hub_url": draco_urls.hub_url()},
status_code=403,
)
# SSO-only instance (v6.7.0): local registration is refused — accounts are
# auto-provisioned by the IdP instead (admins still come from Settings).
from app.services.sso_provisioning import is_sso_only
@@ -310,10 +465,38 @@ def local_login(request: Request, body: dict = Body(default={})):
if not email or not password:
return JSONResponse({"error": "Email and password required"}, status_code=400)
# Porte centrale unique (phase 2) : les identifiants locaux
# ordinaires ne sont plus acceptés — comptes historiques inclus.
# Seule exception : la porte de secours administrateur, par le
# drapeau explicite `secours` (posé par le chemin discret) et les
# identifiants des variables d'environnement. Les sessions déjà
# établies ne passent pas par ici et ne sont pas touchées.
from app.services import reglages_acces as _reglages_acces
if _reglages_acces.porte_centrale_active():
if not body.get("secours"):
return JSONResponse(
{"error": _reglages_acces.message_porte_centrale()},
status_code=403,
)
compte_secours = _reglages_acces.authentifier_secours(email, password)
if compte_secours is None:
return JSONResponse(
{"error": "Porte de secours : identifiants invalides."},
status_code=401,
)
session = SessionManager.create_session(compte_secours, request)
_log_login(compte_secours["id"], request)
response = JSONResponse({"status": "ok", "user": {
"login": compte_secours["login"],
"name": compte_secours["full_name"]}})
response.set_cookie("flowdeck_session", session, httponly=True,
max_age=86400 * 7, samesite="lax", path="/")
return response
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
if not user:
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
return JSONResponse({"error": MESSAGE_ECHEC_CONNEXION_LOCALE}, status_code=401)
ud = dict(user)
if not ud.get("is_active"):
@@ -332,7 +515,7 @@ def local_login(request: Request, body: dict = Body(default={})):
(attempts, lock, ud["id"]),
)
conn.commit()
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
return JSONResponse({"error": MESSAGE_ECHEC_CONNEXION_LOCALE}, status_code=401)
# Successful local login — SSO-only instances keep a way in for admins
# only (every other account must use the IdP, design §7.1).
@@ -483,10 +666,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)
+18 -2
View File
@@ -27,6 +27,22 @@ router = APIRouter(tags=["dashboard"])
def _contexte_accueil() -> dict:
"""Contexte de la page d'accueil publique (fusion #146).
Inscription fermée (défaut) : les boutons « Get started » de
l'accueil cèdent la place à un lien vers le Hub Dracodev, là où
les comptes se créent (URL dérivée de l'hôte, comme les autres
liens de la suite).
"""
from app.services import draco_urls, reglages_acces
return {
"inscription_ouverte": reglages_acces.inscription_locale_ouverte(),
"hub_url": draco_urls.hub_url(),
}
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
@@ -46,13 +62,13 @@ async def dashboard(
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
return template.render(**_contexte_accueil())
except Exception:
logger.exception("dashboard")
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
return template.render(**_contexte_accueil())
# ── Authenticated ──
user_id = user.get("id", 1)
+8
View File
@@ -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
+1 -1
View File
@@ -70,5 +70,5 @@ def recherche_page(request: Request, q: str = Query(default="")):
return HTMLResponse(template.render(
request=request, q=q.strip(), user=user,
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
"kanbante", "agent")},
"kanbante", "shaarit", "agent")},
))
+9
View File
@@ -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", "")
+118 -2
View File
@@ -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
+24 -4
View File
@@ -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(),
}
+155
View File
@@ -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"
+215
View File
@@ -0,0 +1,215 @@
"""Réglages d'accès de l'instance — inscription locale (fusion #146)
et porte centrale unique (phase 2 du programme « compte partout »).
Patron des réglages existants (``llm_config``, ``sso_config``) : une
table à ligne unique (``id = 1``) créée paresseusement, qui prime sur
les valeurs de ``app.config.settings`` (variables d'environnement /
``.env``), elles-mêmes aux défauts du code.
Réglage « inscription locale ouverte » :
- **fermée par défaut** (décision de Bruno, programme « compte
partout ») : les comptes naissent dans le Hub Dracodev et entrent
dans Flowdeck par la voie ObsiGate (SSO) / le provisionnement JIT ;
le parcours public d'inscription locale est refusé proprement.
- réversible à tout moment par un administrateur de l'instance
(``GET``/``PUT /api/admin/reglages-acces``) ou en posant
``INSCRIPTION_LOCALE_OUVERTE=true`` avant tout enregistrement en
base.
Le réglage d'inscription ne touche **jamais** la connexion des
comptes existants, locaux comme draco : il ne garde que la création
de comptes locaux.
Réglage « porte centrale » (phase 2, **distinct** du précédent) :
- **désactivé par défaut à la livraison** ; activé par un
administrateur, l'écran de connexion ne présente que l'entrée
centrale (bouton ObsiGate SSO) et les identifiants locaux
ordinaires ne sont plus acceptés — y compris pour les comptes
locaux historiques. Les sessions déjà établies ne sont pas
touchées, et la voie draco (SSO/JIT) reste la porte normale.
- **Porte de secours administrateur** : UN compte administrateur
local désigné peut encore s'authentifier localement, uniquement
par le chemin discret ``/auth/login?provider=local&secours=1``
(jamais proposé sur l'écran principal). Ses identifiants sont
posés par les variables d'environnement
``FLOWDECK_SECOURS_ADMIN_LOGIN`` et
``FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE`` — aucun secret en dur ;
sans ces variables, la porte de secours est fermée.
"""
from __future__ import annotations
import logging
import os
import secrets
from app.config import settings
logger = logging.getLogger(__name__)
__all__ = [
"get_reglages_acces",
"set_reglages_acces",
"inscription_locale_ouverte",
"message_inscription_fermee",
"porte_centrale_active",
"message_porte_centrale",
"authentifier_secours",
]
def _assurer_table(conn) -> None:
"""Crée la table des réglages d'accès si elle n'existe pas encore
et y ajoute les colonnes des phases ultérieures si besoin."""
conn.execute("""
CREATE TABLE IF NOT EXISTS reglages_acces (
id INTEGER PRIMARY KEY CHECK (id = 1),
inscription_locale_ouverte INTEGER,
porte_centrale_active INTEGER,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
colonnes = {r[1] for r in conn.execute("PRAGMA table_info(reglages_acces)")}
if "porte_centrale_active" not in colonnes:
conn.execute(
"ALTER TABLE reglages_acces ADD COLUMN porte_centrale_active INTEGER")
def get_reglages_acces() -> dict:
"""Réglages d'accès effectifs — la ligne en base prime sur la config."""
reglages = {
# Défaut du code et de la config : inscription fermée.
"inscription_locale_ouverte": bool(
getattr(settings, "inscription_locale_ouverte", False)),
# Défaut du code et de la config : porte centrale désactivée.
"porte_centrale_active": bool(
getattr(settings, "porte_centrale_active", False)),
}
try:
from app.db import get_conn
with get_conn() as conn:
_assurer_table(conn)
row = conn.execute(
"SELECT inscription_locale_ouverte, porte_centrale_active"
" FROM reglages_acces WHERE id=1"
).fetchone()
except Exception: # noqa: BLE001 — base pas encore prête → config seule
return reglages
if row is not None:
if row["inscription_locale_ouverte"] is not None:
reglages["inscription_locale_ouverte"] = bool(
row["inscription_locale_ouverte"])
if row["porte_centrale_active"] is not None:
reglages["porte_centrale_active"] = bool(
row["porte_centrale_active"])
return reglages
def set_reglages_acces(*, inscription_locale_ouverte: bool | None = None,
porte_centrale_active: bool | None = None) -> dict:
"""Enregistre les réglages d'accès (ligne unique) et les renvoie."""
from app.db import get_conn
actuels = get_reglages_acces()
if inscription_locale_ouverte is not None:
actuels["inscription_locale_ouverte"] = bool(inscription_locale_ouverte)
if porte_centrale_active is not None:
actuels["porte_centrale_active"] = bool(porte_centrale_active)
with get_conn() as conn:
_assurer_table(conn)
conn.execute(
"INSERT INTO reglages_acces (id, inscription_locale_ouverte,"
" porte_centrale_active, updated_at) VALUES (1, ?, ?,"
" CURRENT_TIMESTAMP)"
" ON CONFLICT(id) DO UPDATE SET"
" inscription_locale_ouverte=excluded.inscription_locale_ouverte,"
" porte_centrale_active=excluded.porte_centrale_active,"
" updated_at=CURRENT_TIMESTAMP",
(1 if actuels["inscription_locale_ouverte"] else 0,
1 if actuels["porte_centrale_active"] else 0),
)
conn.commit()
return actuels
def inscription_locale_ouverte() -> bool:
"""Vrai si l'inscription publique locale est ouverte sur l'instance."""
return bool(get_reglages_acces()["inscription_locale_ouverte"])
def porte_centrale_active() -> bool:
"""Vrai si la porte centrale unique est active sur l'instance."""
return bool(get_reglages_acces()["porte_centrale_active"])
def message_porte_centrale() -> str:
"""Message affiché quand la porte centrale refuse le local."""
from app.services import draco_urls
return (
"La porte centrale est active sur cette instance : "
"connectez-vous avec le bouton « Login with ObsiGate (SSO) » "
"— les comptes se créent dans le Hub Dracodev "
f"({draco_urls.hub_url()})."
)
def _secours_identifiants() -> tuple[str, str]:
"""Identifiants de la porte de secours — variables d'environnement
en priorité, repli sur la configuration. Jamais de valeur par
défaut : vide = porte de secours fermée."""
login = (os.environ.get("FLOWDECK_SECOURS_ADMIN_LOGIN", "")
or getattr(settings, "secours_admin_login", "") or "").strip()
mot_de_passe = (os.environ.get("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", "")
or getattr(settings, "secours_admin_mot_de_passe", "") or "")
return login, mot_de_passe
def authentifier_secours(login: str, mot_de_passe: str) -> dict | None:
"""Authentifie le compte administrateur de secours désigné.
Les identifiants fournis doivent correspondre **exactement** aux
variables d'environnement de la porte de secours (comparaison à
temps constant), et le compte local correspondant doit exister,
être actif et être administrateur. Renvoie le compte (dict) ou
``None`` — sans jamais dire quelle condition a échoué.
"""
attendu_login, attendu_mdp = _secours_identifiants()
if not attendu_login or not attendu_mdp:
return None
if not secrets.compare_digest((login or "").strip(), attendu_login):
return None
if not secrets.compare_digest(mot_de_passe or "", attendu_mdp):
return None
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE login=? OR email=?",
(attendu_login, attendu_login),
).fetchone()
if row is None:
return None
compte = dict(row)
if not compte.get("is_admin") or not compte.get("is_active"):
return None
return compte
def message_inscription_fermee() -> str:
"""Message affiché quand l'inscription locale est fermée (#146).
Nomme le Hub Dracodev — là où les comptes se créent — avec son URL
dérivée de l'hôte comme les autres liens de la suite
(``draco_urls.hub_url()``, jamais un nom figé).
"""
from app.services import draco_urls
return (
"L'inscription locale est fermée sur cette instance : "
"les comptes se créent dans le Hub Dracodev "
f"({draco_urls.hub_url()}), puis se connectent ici avec le "
"bouton « Login with ObsiGate (SSO) »."
)
+65 -11
View File
@@ -1,7 +1,11 @@
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.9
(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
@@ -13,13 +17,35 @@
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). #}
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&#39;application" title="Changer d&#39;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 }}/">
@@ -40,8 +66,12 @@
<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:#d21f2c" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
<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>
@@ -62,16 +92,12 @@
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ 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).
@@ -82,6 +108,20 @@
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. */
/* Barre v0.6.0 (fusion #142) : JS/CSS re-vendangés verbatim
— le menu compte gagne l'entrée « Administration »
(lien Hub <hub>/admin) à visibilité conditionnelle.
Flowdeck ne passe PAS le drapeau admin : il ne connaît
que le rôle du registre du Hub pour sa propre app
(consulté au pont draco), pas le rôle global de suite
(rôle du compte chez ObsiGate) — à défaut de certitude,
l'entrée reste masquée ici, comme le contrat l'exige. */
urls: { hub: "{{ draco_urls().hub }}/" },
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
@@ -100,6 +140,20 @@
return;
}
if (act === "palette") { location.href = "/recherche"; return; }
if (act === "deconnexion") {
/* Fusion #141 : détruire d'abord la session locale
Flowdeck (GET /auth/logout), puis naviguer vers la
déconnexion globale de l'émettrice (ObsiGate,
GET /sso/logout) avec retour ici — le jti draco y
est révoqué et le cookie effacé sur le domaine
parent : la session de la suite entière se termine. */
var retourFd = encodeURIComponent(location.origin + "/");
var cibleFd = "{{ draco_urls().obsigate }}/sso/logout?return_to=" + retourFd;
fetch("/auth/logout", { credentials: "same-origin" })
.catch(function () { })
.finally(function () { location.href = cibleFd; });
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(); }
+2 -2
View File
@@ -179,7 +179,7 @@
</a>
<div class="landing-nav-actions">
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
<a href="/auth/register" class="btn btn-primary">Get started free</a>
{% if inscription_ouverte %}<a href="/auth/register" class="btn btn-primary">Get started free</a>{% else %}<a href="{{ hub_url }}" class="btn btn-primary">Créer un compte dans le Hub Dracodev</a>{% endif %}
</div>
</nav>
@@ -191,7 +191,7 @@
All on your own server.
</p>
<div class="hero-actions">
<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
{% if inscription_ouverte %}<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>{% else %}<a href="{{ hub_url }}" class="btn btn-primary">Créer un compte dans le Hub Dracodev</a>{% endif %}
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
</div>
</section>
+7 -1
View File
@@ -16,9 +16,10 @@
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 — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante, bookmarks ShaarIt — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
@@ -29,6 +30,7 @@
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="shaarit">ShaarIt <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
@@ -47,6 +49,10 @@
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="shaarit">
<div class="rp-head"><h2>ShaarIt — bookmarks</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
+73 -14
View File
@@ -7,14 +7,39 @@
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
Alertes icône seule.
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
v0.2.0 : nouvel onglet « ShaarIt » (data-app="shaarit", --app émeraude
#059669, logo logos/shaarit.svg via {{LOGO_SHAARIT}}) avant l'onglet
agent ; GARDE data-theme : les blocs prefers-color-scheme ne
s'appliquent que si <html> n'a AUCUN attribut data-theme
(html:not([data-theme])) — avant ce correctif, un hôte ayant choisi
le clair via data-theme="light" héritait quand même du sombre
système (piège constaté dans les copies vendangées de
Kanbante/Flowdeck ; les re-vendanges de la v0.2.0 le corrigent).
v0.3.0 (HUB-1) : aucun style nouveau — les liens du Hub du menu
compte (Télécharger, À Propos, Aides, Profil) réutilisent
.draco-menu-item (déjà ancre-compatible : text-decoration none)
et le séparateur .draco-menu-sep du menu des applications.
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
items du menu compte (SVG inline construit par le JS) : 18 px,
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
ou prefers-color-scheme si l'app intégratrice ne force rien
(aucun attribut data-theme sur <html>). */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
--agent: #c00000; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
@@ -22,8 +47,12 @@
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
/* Garde data-theme (v0.2.0) : le sombre système ne s'applique QUE si
* l'hôte n'a posé aucun data-theme sur <html>. Sans cette garde, un
* hôte en data-theme="light" sur un système sombre affichait la barre
* sombre malgré son choix explicite. */
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
html:not([data-theme]) .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
@@ -37,7 +66,15 @@
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
.draco-brand-mark { line-height: 0; }
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
fond transparent — noire sur barre claire (filtre), blanche sur
barre foncée (filtre annulé par les règles thème ci-dessous). */
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
@media (prefers-color-scheme: dark) {
html:not([data-theme]) .draco-bar .draco-brand-mark img { filter: none; }
}
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
@@ -52,7 +89,7 @@
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
min-width: 232px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
@@ -64,6 +101,20 @@
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
/* Item conditionnel masqué (v0.6.0 : « Administration » tant que
l'hôte n'a pas affirmé le statut d'administrateur) — le
display:flex ci-dessus écraserait l'attribut hidden sans cette
règle, même patron que .draco-compte-menu[hidden]. */
.draco-menu-item[hidden] { display: none; }
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
libellé dans le DOM, replacées à gauche par order: -1. */
.draco-menu-icone {
order: -1; flex: none; display: inline-flex; align-items: center;
width: 18px; height: 18px; color: var(--ink-2);
}
.draco-menu-icone svg { display: block; width: 18px; height: 18px; }
.draco-menu-item:hover .draco-menu-icone,
.draco-menu-item:focus-visible .draco-menu-icone { color: var(--ink); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
@@ -138,18 +189,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;
@@ -176,7 +232,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; }
@@ -200,6 +256,9 @@
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
/* v0.2.0 : sur ShaarIt elle-même (data-current="shaarit"), c'est
l'onglet ShaarIt qui reste visible en mobile. */
.draco-bar[data-current="shaarit"] .draco-apptab[data-app="shaarit"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+9
View File
@@ -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

+19
View File
@@ -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');
+39 -9
View File
@@ -2,11 +2,13 @@
* chantier 2+3 : onglets, « Tout voir », focus).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante, ShaarIt) : la configuration
* vient des attributs data-* de #recherche, rendus côté serveur par
* l'hôte. Source canonique : dracodev-commons, ui/recherche/
* (contrat : contrats/recherche-federee.md).
*
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
* ShaarIt, Console Spark. « Tout » = les 5 sections groupées, chaque liste
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
* de cette application (aucune troncature côté client — la requête
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
@@ -46,6 +48,11 @@
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - ShaarIt : GET {base}/api/search?q=
* -> { links: [{ id, title, url, description, tags, shorturl,
* private }] }
* Lien profond (contrat liens-profonds) : {base}shaare/<shorturl>.
* Recherche native adressable : {base}?q=<q> (page d'accueil).
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* (agent-commun, authentifié par le cookie SSO draco_session) ->
* { q, domain, limite_par_type, resultats[], par_type,
@@ -87,8 +94,9 @@
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* (Domain=.dracodev.net), accepté par les 5 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68,
* ShaarIt via la fédération v0.2.1 de commons)
* et par GET /api/v1/recherche de la console (identity() accepte
* draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
@@ -109,12 +117,14 @@
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
shaarit: "https://shaarit.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
shaarit: "https://shaarit-dev.dracodev.net/",
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
@@ -125,6 +135,7 @@
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
{ key: "shaarit", label: "ShaarIt" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
@@ -145,7 +156,7 @@
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "shaarit", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
@@ -227,7 +238,7 @@
return TABS.indexOf(v) !== -1 ? v : "all";
}
/* Sections visibles pour un onglet. « Tout » = les 4 sections
/* Sections visibles pour un onglet. « Tout » = les 5 sections
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
function visibleKeys(tab) {
tab = parseTab(tab);
@@ -256,7 +267,7 @@
* Chargement -> "…", ok/vide -> total ("0" honnête),
* erreur/indisponible/inactif -> "" (pas de faux 0).
* Tout : "…" tant qu'une section charge, sinon somme des totaux
* connus, "" si les 4 sections sont en erreur/inactives. */
* connus, "" si les 5 sections sont en erreur/inactives. */
function tabCounts(states) {
states = states || {};
var out = {};
@@ -367,6 +378,22 @@
};
});
}
if (app === "shaarit") {
return cut(data.links || []).map(function (l) {
var tags = (l.tags || []).filter(function (t) { return !!t; });
var ctx = "Bookmark";
if (l.private) ctx += " · privé";
if (tags.length) ctx += " · " + tags.join(", ");
return {
title: l.title || l.url || "Sans titre",
snippet: snippetOf(l.description || ""),
context: ctx,
/* Lien profond canonique (contrats/liens-profonds.md) ;
* sans shorturl, repli sur l'URL du bookmark lui-même. */
url: l.shorturl ? base + "shaare/" + l.shorturl : l.url || null,
};
});
}
return [];
}
@@ -455,13 +482,16 @@
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
if (app === "shaarit") {
return base + "?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante") {
if (app === "tiktak" || app === "kanbante" || app === "shaarit") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
+381 -21
View File
@@ -8,7 +8,56 @@
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
* « Menu des applications », « Focus » et « Écouteurs globaux ») ;
* v0.2.0 : 5e application ShaarIt — clé urls.shaarit, défaut officiel
* https://shaarit.dracodev.net/, dérivation -dev shaarit-dev (même
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
* (data-app="shaarit", après Kanbante, avant agent) est repris
* automatiquement par la construction du menu de la marque.
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
* sœurs. Le menu compte gagne trois vrais liens (patron
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
* celui du Hub). « Déconnexion » reste l'action relayée
* (data-compte-act="deconnexion" / onAction), inchangée.
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
* sans étape « href rendu » (voir « URL » ci-dessous).
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
* reçoit un glyphe SVG inline dans le style des icônes du rail de
* la console (contour, trait fin régulier, currentColor, aucun
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
* la barre apprend la préférence de thème du profil du Hub
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
* local court et repli intégral sur le comportement actuel de
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
* v0.6.0 (demande de Bruno, 2026-11-06) : le menu compte gagne un
* cinquième lien Hub, « Administration » → <hub>/admin (le Hub y
* redirige vers /admin/utilisateurs), placé après le séparateur,
* avant « Déconnexion », avec son icône monoline (curseurs de
* réglage). AFFICHAGE CONDITIONNEL : l'entrée n'apparaît que si
* l'hôte AFFIRME que le compte courant est administrateur de
* suite — cfg.admin === true à la configuration, ou
* DRACO_BAR.setAdmin(true) quand l'hôte l'apprend après coup
* (patron setUrls). L'hôte ne passe le drapeau que s'il l'a établi
* depuis SA source d'autorité (rôle global du compte chez
* ObsiGate, niveau du registre des rôles du Hub pour son app…) ;
* à défaut de certitude, pas de drapeau et l'entrée reste masquée.
* La barre ne devine jamais : le jeton draco ne porte aucun rôle
* (claims minimaux, par design). La vraie protection reste la
* garde serveur du Hub (/admin/*), pas ce masquage d'affichage.
* Voir le README, « Administration » — contrat de visibilité.
*
* 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
@@ -21,13 +70,20 @@
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* admin: true, // (v0.6.0, optionnel)
* // administrateur de suite ÉTABLI par l'hôte : démasque
* // l'entrée « Administration » du menu compte. Absent ou
* // non-strictement-true → entrée masquée. Peut aussi être
* // posé après coup via DRACO_BAR.setAdmin(true).
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* shaarit: "https://shaarit.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* spark: "https://spark.dracodev.net/",
* hub: "https://hub.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
@@ -54,10 +110,19 @@
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* elle, a un onglet depuis le chantier 5 (ordre, v0.2.0 : ObsiGate,
* Flowdeck, TikTak, Kanbante, ShaarIt, Console Spark) : elle suit
* donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls.
* la marque et DRACO_BAR.urls. Même résolution pour shaarit (v0.2.0) :
* explicite > dérivation -dev shaarit-dev > href rendu
* ({{URL_SHAARIT}}) > défaut officiel.
* Cas hub (v0.3.0) : PAS d'onglet, donc pas d'étape « href rendu » —
* explicite (DRACO_BAR_CONFIG.urls.hub, alimenté par le jeton
* {{URL_HUB}} remplacé côté serveur par l'hôte) > dérivation -dev
* hub-dev sur hôte -dev > défaut officiel
* https://hub.dracodev.net/. La base résolue sert les quatre liens
* du menu compte (telecharger, a-propos, aide, profil).
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
@@ -132,6 +197,14 @@
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
* Menu compte (v0.3.0) — Profil n'est PLUS relayé : c'est un lien
* vers <hub>/profil, comme Télécharger / À Propos / Aides (liens
* Hub, data-hub-link, href resynchronisés à chaque ouverture via
* refreshCompteHrefs / refreshMenuHrefs). Seule « Déconnexion »
* reste relayée à l'hôte (data-compte-act="deconnexion"), inchangée.
* Les hôtes qui traitaient onAction("profil") (Kanbante → /profile)
* voient ce comportement remplacé par le lien Hub — décision du
* plan Hub : un seul Profil, celui du Hub.
*/
(function () {
"use strict";
@@ -144,8 +217,10 @@
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
shaarit: "https://shaarit.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
spark: "https://spark.dracodev.net/",
hub: "https://hub.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
@@ -157,12 +232,14 @@
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
shaarit: "https://shaarit-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/",
hub: "https://hub-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
tiktak: "TikTak", kanbante: "Kanbante", shaarit: "ShaarIt"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
@@ -218,6 +295,131 @@
}
var resolvedUrls = resolveUrls();
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
* La préférence de thème de la suite vit dans le profil du Hub :
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
* authentifié par le cookie draco (credentials: "include").
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
* défaut officiel) — jamais un nom figé de plus.
* Application par le mécanisme data-theme existant des hôtes :
* "sombre" → data-theme="dark", "clair" → data-theme="light",
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
* html:not([data-theme]) du CSS de la barre comme des hôtes).
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
* propre doit suivre (icônes et libellés, feuilles de style,
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
* s'initialise après la barre lit la même information dans
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
* Ordre d'application : 1) le cache local FRAIS est appliqué de
* façon SYNCHRONE à l'initialisation de la barre (script en defer
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
* a changé. À la toute première visite (aucun cache), un bref
* flash du thème local de l'hôte reste possible le temps du fetch :
* c'est le prix du repli — l'état affiché transitoire est le thème
* que l'hôte aurait de toute façon choisi seul, jamais un état
* cassé, et les visites suivantes sont servies par le cache.
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
* jamais le choix local de l'hôte.
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
* existant. Le comportement actuel de l'hôte (son localStorage,
* puis le système) reste alors exactement le maître, comme avant
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
* fetch) ; elle ne ré-applique ensuite que sur changement du
* schéma système, et seulement tant que la préférence résolue est
* "systeme". */
var THEME_CACHE_CLE = "draco-theme-hub";
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
function themeSystemeEstSombre() {
try {
return !!(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) { return false; }
}
function lireCacheTheme() {
try {
var brut = window.localStorage &&
window.localStorage.getItem(THEME_CACHE_CLE);
if (!brut) return null;
var c = JSON.parse(brut);
if (!c || typeof c.ts !== "number") return null;
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
return c.theme;
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
return null;
}
function ecrireCacheTheme(theme) {
try {
if (window.localStorage) {
window.localStorage.setItem(THEME_CACHE_CLE,
JSON.stringify({ theme: theme, ts: Date.now() }));
}
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
}
function appliquerThemeCommun(theme) {
var root = document.documentElement;
if (!root) return;
if (theme === "sombre" || theme === "clair") {
themeMode = theme === "sombre" ? "dark" : "light";
root.setAttribute("data-theme", themeMode);
} else if (theme === "systeme") {
themeMode = themeSystemeEstSombre() ? "dark" : "light";
root.removeAttribute("data-theme");
} else {
return;
}
themePref = theme;
if (window.DRACO_BAR) {
window.DRACO_BAR.themePref = themePref;
window.DRACO_BAR.themeMode = themeMode;
}
try {
document.dispatchEvent(new CustomEvent("draco:theme",
{ detail: { theme: themePref, mode: themeMode } }));
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
}
function initThemeCommun() {
var enCache = lireCacheTheme();
if (enCache) appliquerThemeCommun(enCache);
/* Suivi du schéma système tant que la préférence est "systeme" :
* sans data-theme, le CSS suit tout seul ; l'événement est
* re-dispatché pour les hôtes qui matérialisent le mode. */
try {
var mq = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", function () {
if (themePref === "systeme") appliquerThemeCommun("systeme");
});
}
} catch (e) { /* pas de matchMedia : pas de suivi */ }
if (typeof window.fetch !== "function") return;
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
"api/profil/theme";
window.fetch(url, { credentials: "include" }).then(function (resp) {
if (!resp || !resp.ok) return null;
return resp.json();
}).then(function (data) {
if (!data) return;
var t = data.theme;
if (t === "sombre" || t === "clair" || t === "systeme") {
ecrireCacheTheme(t);
appliquerThemeCommun(t);
}
/* theme null ou inconnu : repli intégral, rien n'est touché. */
}).catch(function () { /* Hub injoignable : repli intégral */ });
}
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -296,6 +498,28 @@
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
/* v0.3.0 : les liens du Hub (menu compte) sont resynchronisés
* eux aussi à l'ouverture d'un menu, pour refléter setUrls et
* la dérivation -dev même s'ils ont été construits avant. */
refreshCompteHrefs();
}
/* Liens du Hub dans le menu compte (v0.3.0) : chemin → libellé.
* « Profil » est le quatrième lien (vers <hub>/profil) ; il
* remplace l'ancien item relayé « profil ». */
var HUB_LINKS = [
["telecharger", "Télécharger"],
["a-propos", "À Propos"],
["aide", "Aides"],
["profil", "Profil"]
];
function hubHref(path) {
return withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) + path;
}
function refreshCompteHrefs() {
if (!compteMenu) return;
compteMenu.querySelectorAll("[data-hub-link]").forEach(function (a) {
a.setAttribute("href", hubHref(a.getAttribute("data-hub-link")));
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
@@ -365,7 +589,8 @@
if (badge) a.appendChild(badge.cloneNode(true));
appsMenu.appendChild(a);
});
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
/* Entrée « Console Spark » (#70) : après les onglets d'apps
* (ObsiGate…ShaarIt, v0.2.0), vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
@@ -417,10 +642,101 @@
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
/* Icônes monoline du menu compte (v0.4.0) — glyphes contour
* dans le style des icônes du rail de la console : viewBox 24,
* stroke currentColor, remplissage nul, extrémités rondes.
* Chaque glyphe est une liste de primitives [balise, attributs]
* construites en DOM (jamais d'innerHTML), comme le reste du
* fichier. Le libellé reste le nœud texte de l'item ; l'icône,
* appendue après, est replacée à gauche par le CSS (order: -1
* sur .draco-menu-icone, l'item étant déjà en flex). */
var MENU_ICONES = {
/* Flèche vers le bas dans un plateau. */
"telecharger": [
["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["polyline", { points: "7 10 12 15 17 10" }],
["line", { x1: "12", y1: "15", x2: "12", y2: "3" }]
],
/* Cercle d'information. */
"a-propos": [
["circle", { cx: "12", cy: "12", r: "9" }],
["line", { x1: "12", y1: "11", x2: "12", y2: "16" }],
["line", { x1: "12", y1: "8", x2: "12.01", y2: "8" }]
],
/* Cercle d'interrogation. */
"aide": [
["circle", { cx: "12", cy: "12", r: "9" }],
["path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }]
],
/* Personne : tête + épaules. */
"profil": [
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
["circle", { cx: "12", cy: "7", r: "4" }]
],
/* Curseurs de réglage (trois axes, trois boutons) — v0.6.0. */
"admin": [
["line", { x1: "4", y1: "21", x2: "4", y2: "14" }],
["line", { x1: "4", y1: "10", x2: "4", y2: "3" }],
["line", { x1: "12", y1: "21", x2: "12", y2: "12" }],
["line", { x1: "12", y1: "8", x2: "12", y2: "3" }],
["line", { x1: "20", y1: "21", x2: "20", y2: "16" }],
["line", { x1: "20", y1: "12", x2: "20", y2: "3" }],
["line", { x1: "1", y1: "14", x2: "7", y2: "14" }],
["line", { x1: "9", y1: "8", x2: "15", y2: "8" }],
["line", { x1: "17", y1: "16", x2: "23", y2: "16" }]
],
/* Flèche sortant d'un cadre (porte). */
"deconnexion": [
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
["polyline", { points: "16 17 21 12 16 7" }],
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
span.appendChild(svg);
return span;
}
/* Visibilité de l'entrée « Administration » (v0.6.0) : vraie
* seulement si l'hôte l'a AFFIRMÉE — cfg.admin === true à la
* configuration, ou DRACO_BAR.setAdmin(true) après coup. La
* barre ne déduit jamais ce statut (le jeton draco ne porte
* aucun rôle) ; à défaut, l'entrée reste masquée et la garde
* serveur du Hub reste la vraie protection. */
var compteAdmin = cfg.admin === true;
var adminItem = null;
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
* d'identité, puis les liens du Hub (Télécharger, À Propos,
* Aides, Profil — vrais <a href>, même patron que le menu des
* applications), un séparateur, puis « Déconnexion » relayée à
* l'hôte. L'en-tête et les href sont rafraîchis à chaque
* ouverture, pour refléter un title posé après coup par l'hôte
* (ex. ObsiGate, qui remplit l'avatar depuis /api/auth/me après
* chargement) et la résolution d'URL la plus récente. */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
@@ -431,15 +747,44 @@
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);
/* « Administration » (v0.6.0) : cinquième lien Hub
* (<hub>/admin, où le Hub redirige vers /admin/utilisateurs).
* data-hub-link="admin" : son href est resynchronisé par
* refreshCompteHrefs / setUrls comme les quatre autres liens.
* Toujours construit ; masqué tant que l'hôte n'a pas affirmé
* le statut d'administrateur (compteAdmin). */
var adm = document.createElement("a");
adm.className = "draco-menu-item";
adm.href = hubHref("admin");
adm.setAttribute("data-hub-link", "admin");
adm.setAttribute("role", "menuitem");
adm.textContent = "Administration";
adm.appendChild(menuIcone("admin"));
adm.hidden = !compteAdmin;
compteMenu.appendChild(adm);
adminItem = adm;
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", "deconnexion");
b.setAttribute("role", "menuitem");
b.textContent = "Déconnexion";
b.appendChild(menuIcone("deconnexion"));
compteMenu.appendChild(b);
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
@@ -447,6 +792,8 @@
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
if (adminItem) adminItem.hidden = !compteAdmin;
refreshCompteHrefs();
}
function toggleCompteMenu(force) {
buildCompteMenu();
@@ -538,11 +885,23 @@
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
themePref: null, themeMode: null,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
refreshCompteHrefs();
},
/* Administration (v0.6.0) : l'hôte pose le drapeau quand il a
* ÉTABLI que le compte courant est administrateur de suite
* (sa source d'autorité — voir l'en-tête et le README). Toute
* valeur autre que le booléen true masque l'entrée. */
setAdmin: function (v) {
compteAdmin = v === true;
if (adminItem) adminItem.hidden = !compteAdmin;
}
};
@@ -551,6 +910,7 @@
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
initThemeCommun();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+8
View File
@@ -127,6 +127,14 @@ def client():
from app.main import app
init_db()
# Fusion #146 : l'inscription locale est fermée par défaut en
# production (les comptes naissent au Hub). L'environnement de
# test l'ouvre explicitement pour les suites historiques qui
# créent leurs comptes par /auth/register ; les tests du réglage
# (tests/test_reglages_acces.py) la ferment eux-mêmes.
from app.services import reglages_acces
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
yield login_test_client(TestClient(app))
# Cleanup
+21 -5
View File
@@ -61,8 +61,18 @@ items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
/* normalize ShaarIt (commons v0.2.1) : lien profond shaare/<shorturl>,
* contexte privé + tags, repli sur l'URL du bookmark sans shorturl */
items = rp.normalize("shaarit", { links: [{ title: "Yandex", url: "https://yandex.com", description: "moteur", tags: ["recherche", "web"], shorturl: "xYz9", private: true }] }, "https://shaarit-dev.dracodev.net/", "recherche");
ok(items[0].title === "Yandex" && items[0].url === "https://shaarit-dev.dracodev.net/shaare/xYz9", "lien profond ShaarIt (shaare/<shorturl>)");
ok(items[0].context === "Bookmark · privé · recherche, web", "contexte ShaarIt (privé + tags)");
ok(items[0].snippet === "moteur", "extrait ShaarIt = description");
items = rp.normalize("shaarit", { links: [{ title: "", url: "https://exemple.fr", tags: [], shorturl: "" }] }, "https://shaarit.dracodev.net/", "x");
ok(items[0].url === "https://exemple.fr" && items[0].title === "https://exemple.fr", "ShaarIt sans shorturl : repli URL + titre");
/* nativeSearchUrl */
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
ok(rp.nativeSearchUrl("shaarit", "https://shaarit-dev.dracodev.net/", "q") === "https://shaarit-dev.dracodev.net/?q=q", "repli ShaarIt = accueil ?q=");
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
@@ -70,15 +80,17 @@ ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "http
/* parseTab */
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
ok(rp.parseTab("shaarit") === "shaarit", "onglet shaarit valide");
ok(rp.parseTab("console") === "console", "onglet console valide");
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
/* visibleKeys : Tout = les 5 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "Tout = 5 sections, pas la console");
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
ok(JSON.stringify(rp.visibleKeys("shaarit")) === JSON.stringify(["shaarit"]), "onglet shaarit = sa seule section");
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "onglet inconnu -> Tout");
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
@@ -91,9 +103,11 @@ let c = rp.tabCounts({
flowdeck: { status: "ok", total: 3 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "ok", total: 2 },
console: { status: "idle", total: 0 },
});
ok(c.obsigate === "…", "compteur … pendant le chargement");
ok(c.shaarit === "2", "compteur shaarit");
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
ok(c.all === "…", "Tout = … tant qu'une section charge");
@@ -102,18 +116,20 @@ c = rp.tabCounts({
flowdeck: { status: "ok", total: 2 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "ok", total: 1 },
console: { status: "ok", total: 4 },
});
ok(c.all === "7", "Tout = somme des totaux connus");
ok(c.all === "8", "Tout = somme des totaux connus (5 sections)");
ok(c.console === "4", "compteur console");
c = rp.tabCounts({
obsigate: { status: "error", total: 0 },
flowdeck: { status: "error", total: 0 },
tiktak: { status: "error", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
ok(c.all === "", "Tout vide si les 5 sections sont en erreur");
/* buildSearchUrl : état onglet partageable */
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
+6
View File
@@ -31,6 +31,12 @@ def client():
from app.main import app
init_db()
# Fusion #146 : inscription fermée par défaut en production —
# cette suite historique crée ses comptes par /auth/register,
# elle ouvre donc le réglage explicitement (comme conftest).
from app.services import reglages_acces
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
# A default user id=1 so tests that reference the implicit user (favorites,
# workspace ownership) satisfy foreign keys without relying on leaked state.
from app.db import get_conn
+114
View File
@@ -33,6 +33,104 @@ def test_aucun_texte_chantier_rendu():
assert rendu.lstrip().startswith("<link")
def test_barre_deconnexion_branchee_globale():
"""Déconnexion du menu compte (fusion #141) : le gabarit branche
l'action « deconnexion » — destruction de la session locale
(/auth/logout) puis navigation vers la déconnexion globale de
l'émettrice (GET /sso/logout chez ObsiGate, retour ici). Avant le
correctif, aucune branche ne traitait l'action : clic sans effet."""
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
assert 'act === "deconnexion"' in gabarit
assert 'fetch("/auth/logout"' in gabarit
assert '{{ draco_urls().obsigate }}/sso/logout?return_to=' in gabarit
def test_barre_v060_entree_administration_vendangee():
"""Barre v0.6.0 (fusion #142) : JS/CSS vendangés portent l'entrée
« Administration » du menu compte (lien Hub data-hub-link
« admin », setAdmin, règle CSS [hidden]) ; le gabarit documente
pourquoi Flowdeck ne passe pas le drapeau (pas de rôle global
connu) — aucun « admin: true » dans la configuration servie."""
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
assert 'setAttribute("data-hub-link", "admin")' in js
assert '"Administration"' in js
assert "setAdmin" in js
css = (ROOT / "static/css/draco-barre.css").read_text()
assert ".draco-menu-item[hidden]" in css
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
assert "v0.6.0" in gabarit
assert "admin: true" not in gabarit
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()
@@ -46,3 +144,19 @@ def test_barre_intacte_apres_suppression():
# 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
+37 -1
View File
@@ -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")
+72 -8
View File
@@ -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
+308
View File
@@ -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
+222
View File
@@ -0,0 +1,222 @@
"""FlowDeck — porte centrale unique (phase 2, programme « compte
partout »), basée sur les réglages d'accès de la phase 1 (#146).
Contrat :
- réglage « porte centrale » **distinct** de celui d'inscription,
**désactivé par défaut** à la livraison ;
- mode actif : l'écran de connexion ne présente que l'entrée
centrale (bouton ObsiGate SSO) et les identifiants locaux
ordinaires sont refusés — comptes historiques inclus ; les
sessions déjà établies ne sont pas touchées ;
- **porte de secours** : UN compte administrateur local désigné,
identifiants posés par les variables d'environnement
``FLOWDECK_SECOURS_ADMIN_LOGIN`` /
``FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE``, authentification locale
uniquement par le chemin discret ``?secours=1`` (drapeau
``secours`` à l'envoi) — jamais proposé sur l'écran principal ;
- mode inactif : comportement actuel intégralement préservé.
"""
from __future__ import annotations
import pytest
from conftest import anon_csrf
from app.services import reglages_acces
LOGIN_SECOURS = "admin-secours"
MDP_SECOURS = "mot-de-passe-de-secours-designe"
def _activer():
reglages_acces.set_reglages_acces(porte_centrale_active=True)
def _desactiver():
reglages_acces.set_reglages_acces(porte_centrale_active=False)
def _creer_compte_local(login: str, mot_de_passe: str, admin: int = 0) -> None:
from app.db import get_conn
from app.password_utils import hash_password
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO users (login, full_name, email,"
" password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
(login, login, f"{login}@test.dev",
hash_password(mot_de_passe), admin),
)
conn.commit()
@pytest.fixture
def secours_env(monkeypatch):
"""Pose les variables d'environnement de la porte de secours."""
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_LOGIN", LOGIN_SECOURS)
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", MDP_SECOURS)
_creer_compte_local(LOGIN_SECOURS, "ancien-mot-de-passe-local", admin=1)
return monkeypatch
# ── Défaut et distinction des réglages ──────────────────────────────
def test_porte_centrale_desactivee_par_defaut_dans_le_code():
from app.config import settings
assert settings.porte_centrale_active is False
def test_reglage_distinct_de_celui_d_inscription(client):
reglages_acces.set_reglages_acces(
inscription_locale_ouverte=True, porte_centrale_active=False)
reglages = reglages_acces.get_reglages_acces()
assert reglages["inscription_locale_ouverte"] is True
assert reglages["porte_centrale_active"] is False
# ── Mode actif : le local ordinaire est refusé ──────────────────────
def test_mode_actif_refuse_compte_ordinaire(client):
_creer_compte_local("ordinaire", "secret123")
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "ordinaire", "password": "secret123"})
assert r.status_code == 403
assert "ObsiGate (SSO)" in r.json()["error"]
def test_mode_actif_refuse_compte_local_historique(client):
"""Compte créé par inscription publique (quand elle était
ouverte), puis porte activée : son mot de passe local ne passe
plus."""
reglages_acces.set_reglages_acces(
inscription_locale_ouverte=True, porte_centrale_active=False)
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "Historique"})
assert r.status_code == 200
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "[email protected]", "password": "secret123"})
assert r.status_code == 403
def test_mode_actif_ecran_connexion_que_l_entree_centrale(client):
_activer()
r = anon_csrf(client).get("/auth/login?provider=local")
assert r.status_code == 200
# L'entrée centrale, bien visible…
assert "Login with ObsiGate (SSO)" in r.text
# … et rien d'autre : ni formulaire local, ni onglet Register,
# ni lien vers le chemin discret de secours.
assert 'id="login-form"' not in r.text
assert 'id="password"' not in r.text
assert 'id="tab-register"' not in r.text
assert "secours" not in r.text
def test_mode_actif_inscription_aussi_fermee(client):
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
_activer()
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123"})
assert r.status_code == 403
def test_mode_actif_session_deja_etablie_non_touchee(client):
_creer_compte_local("deja-dedans", "secret123")
c = anon_csrf(client)
r = c.post("/auth/local-login", json={
"email": "deja-dedans", "password": "secret123"})
assert r.status_code == 200
_activer()
r = c.get("/auth/user")
assert r.json()["authenticated"] is True
# ── Porte de secours administrateur ─────────────────────────────────
def test_secours_connexion_admin_par_le_chemin_discret(client, secours_env):
_activer()
# L'écran discret rend le formulaire, marqué « secours ».
r = anon_csrf(client).get("/auth/login?provider=local&secours=1")
assert r.status_code == 200
assert 'id="login-form"' in r.text
assert "secours:true" in r.text
# L'authentification elle-même : identifiants d'environnement.
r = anon_csrf(client).post("/auth/local-login", json={
"email": LOGIN_SECOURS, "password": MDP_SECOURS,
"secours": True})
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert "flowdeck_session" in r.headers.get("set-cookie", "")
def test_secours_mauvais_mot_de_passe_environnement_refuse(client, secours_env):
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": LOGIN_SECOURS, "password": "pas-le-bon",
"secours": True})
assert r.status_code == 401
def test_secours_sans_drapeau_refuse_meme_pour_l_admin(client, secours_env):
"""Sans le drapeau du chemin discret, même les identifiants de
secours sont traités comme un envoi ordinaire : refusés."""
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": LOGIN_SECOURS, "password": MDP_SECOURS})
assert r.status_code == 403
def test_secours_compte_non_admin_refuse(client, secours_env, monkeypatch):
"""Le compte désigné doit être administrateur : un compte
ordinaire désigné par les variables ne passe pas."""
_creer_compte_local("pas-admin", "secret123", admin=0)
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_LOGIN", "pas-admin")
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", "secret123")
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "pas-admin", "password": "secret123", "secours": True})
assert r.status_code == 401
def test_secours_sans_variables_environnement_fermee(client, monkeypatch):
monkeypatch.delenv("FLOWDECK_SECOURS_ADMIN_LOGIN", raising=False)
monkeypatch.delenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", raising=False)
_creer_compte_local("admin-local", "secret123", admin=1)
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "admin-local", "password": "secret123", "secours": True})
assert r.status_code == 401
# ── Mode inactif : comportement actuel intégralement préservé ───────
def test_mode_inactif_connexion_locale_comme_avant(client):
_desactiver()
_creer_compte_local("normal", "secret123")
r = anon_csrf(client).post("/auth/local-login", json={
"email": "normal", "password": "secret123"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_mode_inactif_ecran_connexion_complet(client):
_desactiver()
r = anon_csrf(client).get("/auth/login?provider=local")
assert r.status_code == 200
assert 'id="login-form"' in r.text
assert "Login with ObsiGate (SSO)" in r.text
def test_api_admin_aller_retour_porte_centrale(client):
_desactiver()
r = client.put("/api/admin/reglages-acces",
json={"porte_centrale_active": True})
assert r.status_code == 200
assert r.json()["porte_centrale_active"] is True
r = client.get("/api/admin/reglages-acces")
assert r.json()["porte_centrale_active"] is True
_desactiver()
+5 -2
View File
@@ -38,17 +38,20 @@ def test_origin_of():
def test_sister_origins_dev():
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
# quelle que soit la résolution.
# Correctif 2026-10-28 : origines officielles prod ET dev, quelle
# que soit la résolution ; ShaarIt ajoutée par la fédération
# v0.2.1 (12 origines : 10 sœurs + 2 agent).
assert sister_origins(RESOLVED_DEV) == {
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://shaarit.dracodev.net",
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://shaarit-dev.dracodev.net",
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
}
+17
View File
@@ -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
+3 -2
View File
@@ -40,14 +40,15 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ONGLETS_ATTENDUS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante",
"console"]
"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", "console"):
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
+177
View File
@@ -0,0 +1,177 @@
"""FlowDeck — réglages d'accès : inscription locale (fusion #146, #144).
Contrat de la phase 1 du programme « compte partout » :
- inscription locale **fermée par défaut** (décision de Bruno) :
le parcours public d'inscription est refusé proprement et nomme
le Hub Dracodev, là où les comptes se créent ;
- réglage **réversible par un administrateur** (API
``/api/admin/reglages-acces``) : ouverte, l'inscription redevient
exactement celle d'aujourd'hui ;
- la connexion des comptes existants (locaux et draco) n'est
**jamais** affectée par le réglage ;
- #144 : l'échec du formulaire de connexion locale suggère la voie
ObsiGate (SSO).
Le harnais de test ouvre l'inscription pour les suites historiques
(voir conftest) ; chaque test pose donc explicitement l'état qu'il
vérifie via le service des réglages.
"""
from __future__ import annotations
from conftest import anon_csrf
from app.services import reglages_acces
def _fermer():
reglages_acces.set_reglages_acces(inscription_locale_ouverte=False)
def _ouvrir():
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
def _creer_compte_local(login: str, mot_de_passe: str, admin: int = 0) -> None:
from app.db import get_conn
from app.password_utils import hash_password
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO users (login, full_name, email,"
" password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
(login, login, f"{login}@test.dev",
hash_password(mot_de_passe), admin),
)
conn.commit()
# ── Réglage fermé : inscription impossible + message ────────────────
def test_inscription_fermee_par_defaut_dans_le_code():
"""Le défaut du code (hors toute ligne en base) est « fermée »."""
from app.config import settings
assert settings.inscription_locale_ouverte is False
def test_inscription_fermee_post_refuse_avec_message_hub(client):
_fermer()
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "Visiteur"})
assert r.status_code == 403
erreur = r.json()["error"]
assert "Hub Dracodev" in erreur
# Lien dérivé vers le Hub fourni à l'écran comme aux API.
assert r.json()["hub_url"]
# Aucun compte n'a été créé.
from app.db import get_conn
with get_conn() as conn:
assert conn.execute(
"SELECT COUNT(*) FROM users WHERE login='[email protected]'"
).fetchone()[0] == 0
def test_inscription_fermee_page_refus_propre(client):
_fermer()
r = anon_csrf(client).get("/auth/register", follow_redirects=False)
assert r.status_code == 403
assert "Hub Dracodev" in r.text
assert "les comptes se créent" in r.text
def test_inscription_fermee_page_connexion_sans_onglet_register(client):
_fermer()
r = anon_csrf(client).get("/auth/login?provider=local")
assert r.status_code == 200
assert 'id="tab-register"' not in r.text
# L'avis nomme le Hub et la voie SSO reste visible.
assert "Hub Dracodev" in r.text
assert "Login with ObsiGate (SSO)" in r.text
# ── Réglage ouvert : inscription comme aujourd'hui ──────────────────
def test_inscription_ouverte_post_comme_avant(client):
_ouvrir()
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "Nouveau"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_inscription_ouverte_page_register_affichee(client):
_ouvrir()
r = anon_csrf(client).get("/auth/register", follow_redirects=False)
assert r.status_code == 200
assert 'id="tab-register"' in r.text
# ── Connexion des comptes existants : jamais affectée ───────────────
def test_connexion_compte_local_existant_inscription_fermee(client):
_creer_compte_local("ancien", "secret123")
_fermer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "ancien", "password": "secret123"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_connexion_compte_draco_sans_mot_de_passe_inchangee(client):
"""Un compte draco (sans mot de passe local) reste refusé en local
de la même façon, réglage ouvert ou fermé : le réglage ne change
rien à l'authentification."""
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO users (login, full_name, email,"
" password_hash, auth_method) VALUES"
" ('draco-42', 'Dan Test', '[email protected]', NULL, 'draco')")
conn.commit()
for ouvrir in (True, False):
reglages_acces.set_reglages_acces(
inscription_locale_ouverte=ouvrir)
r = anon_csrf(client).post("/auth/local-login", json={
"email": "draco-42", "password": "nimporte"})
assert r.status_code == 401
# ── #144 : l'échec local suggère la voie ObsiGate (SSO) ─────────────
def test_echec_connexion_locale_suggere_sso(client):
_creer_compte_local("quelquun", "secret123")
r = anon_csrf(client).post("/auth/local-login", json={
"email": "quelquun", "password": "mauvais-mot-de-passe"})
assert r.status_code == 401
assert "ObsiGate (SSO)" in r.json()["error"]
assert "Hub Dracodev" in r.json()["error"]
def test_echec_connexion_compte_inconnu_suggere_sso(client):
r = anon_csrf(client).post("/auth/local-login", json={
"email": "[email protected]", "password": "nimporte"})
assert r.status_code == 401
assert "ObsiGate (SSO)" in r.json()["error"]
# ── Administration du réglage ────────────────────────────────────────
def test_api_admin_reglages_acces_aller_retour(client):
"""Le client de test est administrateur : lecture + écriture."""
_fermer()
r = client.get("/api/admin/reglages-acces")
assert r.status_code == 200
assert r.json()["inscription_locale_ouverte"] is False
r = client.put("/api/admin/reglages-acces",
json={"inscription_locale_ouverte": True})
assert r.status_code == 200
assert r.json()["inscription_locale_ouverte"] is True
assert reglages_acces.inscription_locale_ouverte() is True
def test_api_admin_reglages_acces_refusee_aux_anonymes(client):
r = anon_csrf(client).get("/api/admin/reglages-acces")
assert r.status_code in (401, 403)
+5
View File
@@ -42,6 +42,11 @@ def client():
settings.agent_run_timeout_seconds = 30
init_db()
# Fusion #146 : inscription fermée par défaut en production —
# _token() crée ses comptes par /auth/register, la suite ouvre
# donc le réglage explicitement (comme conftest).
from app.services import reglages_acces
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "