Compare commits

...
Author SHA1 Message Date
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
22 changed files with 756 additions and 54 deletions
+47
View File
@@ -1,3 +1,50 @@
## 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é.
+1 -1
View File
@@ -1 +1 @@
7.84.10
7.85.1
+2
View File
@@ -157,6 +157,7 @@ class Settings(BaseSettings):
# 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) ──
@@ -170,6 +171,7 @@ class Settings(BaseSettings):
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.1"
# 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
+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")},
))
+85 -1
View File
@@ -15,6 +15,17 @@ 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``.
"""
from __future__ import annotations
@@ -138,8 +149,79 @@ def _linked_user(conn, sub: str) -> dict | None:
return 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) -> dict | None:
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou 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``.
"""
if not enabled():
return None
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
@@ -153,6 +235,8 @@ def user_for_draco(request) -> dict | None:
return None
with get_conn() as conn:
user = _linked_user(conn, sub)
if user is None:
user = _provision_jit(conn, claims)
if user is not None and not user.get("is_active"):
return None
return user
+12 -1
View File
@@ -98,6 +98,7 @@ OFFICIELLES = {
"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",
}
@@ -123,6 +124,7 @@ _SLUGS_SOEURS = {
"tiktak": "tiktak",
"kanbante": "kanbante",
"shaarit": "shaarit",
"hub": "hub",
}
# Hôte de l'agent : agent<SUFFIXE>.dracodev.net (SUFFIXE = ``-dev``…).
@@ -133,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"}
@@ -282,6 +284,14 @@ 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")
@@ -366,6 +376,7 @@ def toutes() -> dict[str, str]:
"kanbante": kanbante_url(),
"shaarit": shaarit_url(),
"flowdeck": flowdeck_url(),
"hub": hub_url(),
"gitea": gitea_url(),
}
+12 -3
View File
@@ -1,8 +1,11 @@
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.2.0
(ui/barre-apps/, commit 70c837a de la branche
shaarit-phase3-barre-contrats) : ne pas modifier la structure ici ;
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
@@ -105,6 +108,12 @@
repli si le module ne charge pas). */
window.DRACO_BAR_CONFIG = {
current: "flowdeck",
/* Barre v0.3.0 (HUB-1) : le Hub n'a pas d'onglet — sa base
(draco_urls().hub : HUB_URL explicite, hôte de la requête,
dérivation par l'agent, défaut officiel) alimente les
liens du menu compte (Télécharger, À Propos, Aides,
Profil) via urls.hub. */
urls: { hub: "{{ draco_urls().hub }}/" },
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
+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>
+21 -1
View File
@@ -20,7 +20,18 @@
le clair via data-theme="light" héritait quand même du sombre
système (piège constaté dans les copies vendangées de
Kanbante/Flowdeck ; les re-vendanges de la v0.2.0 le corrigent).
v0.3.0 (HUB-1) : aucun style nouveau — les liens du Hub du menu
compte (Télécharger, À Propos, Aides, Profil) réutilisent
.draco-menu-item (déjà ancre-compatible : text-decoration none)
et le séparateur .draco-menu-sep du menu des applications.
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
items du menu compte (SVG inline construit par le JS) : 18 px,
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien
(aucun attribut data-theme sur <html>). */
@@ -78,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);
}
@@ -90,6 +101,15 @@
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
libellé dans le DOM, replacées à gauche par order: -1. */
.draco-menu-icone {
order: -1; flex: none; display: inline-flex; align-items: center;
width: 18px; height: 18px; color: var(--ink-2);
}
.draco-menu-icone svg { display: block; width: 18px; height: 18px; }
.draco-menu-item:hover .draco-menu-icone,
.draco-menu-item:focus-visible .draco-menu-icone { color: var(--ink); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
+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)";
+296 -15
View File
@@ -14,6 +14,34 @@
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
* (data-app="shaarit", après Kanbante, avant agent) est repris
* automatiquement par la construction du menu de la marque.
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
* sœurs. Le menu compte gagne trois vrais liens (patron
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
* celui du Hub). « Déconnexion » reste l'action relayée
* (data-compte-act="deconnexion" / onAction), inchangée.
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
* sans étape « href rendu » (voir « URL » ci-dessous).
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
* reçoit un glyphe SVG inline dans le style des icônes du rail de
* la console (contour, trait fin régulier, currentColor, aucun
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
* la barre apprend la préférence de thème du profil du Hub
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
* local court et repli intégral sur le comportement actuel de
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -33,7 +61,8 @@
* 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
@@ -67,6 +96,12 @@
* 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…).
@@ -141,6 +176,14 @@
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
* Menu compte (v0.3.0) — Profil n'est PLUS relayé : c'est un lien
* vers <hub>/profil, comme Télécharger / À Propos / Aides (liens
* Hub, data-hub-link, href resynchronisés à chaque ouverture via
* refreshCompteHrefs / refreshMenuHrefs). Seule « Déconnexion »
* reste relayée à l'hôte (data-compte-act="deconnexion"), inchangée.
* Les hôtes qui traitaient onAction("profil") (Kanbante → /profile)
* voient ce comportement remplacé par le lien Hub — décision du
* plan Hub : un seul Profil, celui du Hub.
*/
(function () {
"use strict";
@@ -155,7 +198,8 @@
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
@@ -168,7 +212,8 @@
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
shaarit: "https://shaarit-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
agent: "https://agent-dev.dracodev.net/",
hub: "https://hub-dev.dracodev.net/"
};
var APP_LABELS = {
@@ -229,6 +274,131 @@
}
var resolvedUrls = resolveUrls();
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
* La préférence de thème de la suite vit dans le profil du Hub :
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
* authentifié par le cookie draco (credentials: "include").
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
* défaut officiel) — jamais un nom figé de plus.
* Application par le mécanisme data-theme existant des hôtes :
* "sombre" → data-theme="dark", "clair" → data-theme="light",
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
* html:not([data-theme]) du CSS de la barre comme des hôtes).
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
* propre doit suivre (icônes et libellés, feuilles de style,
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
* s'initialise après la barre lit la même information dans
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
* Ordre d'application : 1) le cache local FRAIS est appliqué de
* façon SYNCHRONE à l'initialisation de la barre (script en defer
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
* a changé. À la toute première visite (aucun cache), un bref
* flash du thème local de l'hôte reste possible le temps du fetch :
* c'est le prix du repli — l'état affiché transitoire est le thème
* que l'hôte aurait de toute façon choisi seul, jamais un état
* cassé, et les visites suivantes sont servies par le cache.
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
* jamais le choix local de l'hôte.
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
* existant. Le comportement actuel de l'hôte (son localStorage,
* puis le système) reste alors exactement le maître, comme avant
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
* fetch) ; elle ne ré-applique ensuite que sur changement du
* schéma système, et seulement tant que la préférence résolue est
* "systeme". */
var THEME_CACHE_CLE = "draco-theme-hub";
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
function themeSystemeEstSombre() {
try {
return !!(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) { return false; }
}
function lireCacheTheme() {
try {
var brut = window.localStorage &&
window.localStorage.getItem(THEME_CACHE_CLE);
if (!brut) return null;
var c = JSON.parse(brut);
if (!c || typeof c.ts !== "number") return null;
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
return c.theme;
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
return null;
}
function ecrireCacheTheme(theme) {
try {
if (window.localStorage) {
window.localStorage.setItem(THEME_CACHE_CLE,
JSON.stringify({ theme: theme, ts: Date.now() }));
}
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
}
function appliquerThemeCommun(theme) {
var root = document.documentElement;
if (!root) return;
if (theme === "sombre" || theme === "clair") {
themeMode = theme === "sombre" ? "dark" : "light";
root.setAttribute("data-theme", themeMode);
} else if (theme === "systeme") {
themeMode = themeSystemeEstSombre() ? "dark" : "light";
root.removeAttribute("data-theme");
} else {
return;
}
themePref = theme;
if (window.DRACO_BAR) {
window.DRACO_BAR.themePref = themePref;
window.DRACO_BAR.themeMode = themeMode;
}
try {
document.dispatchEvent(new CustomEvent("draco:theme",
{ detail: { theme: themePref, mode: themeMode } }));
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
}
function initThemeCommun() {
var enCache = lireCacheTheme();
if (enCache) appliquerThemeCommun(enCache);
/* Suivi du schéma système tant que la préférence est "systeme" :
* sans data-theme, le CSS suit tout seul ; l'événement est
* re-dispatché pour les hôtes qui matérialisent le mode. */
try {
var mq = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", function () {
if (themePref === "systeme") appliquerThemeCommun("systeme");
});
}
} catch (e) { /* pas de matchMedia : pas de suivi */ }
if (typeof window.fetch !== "function") return;
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
"api/profil/theme";
window.fetch(url, { credentials: "include" }).then(function (resp) {
if (!resp || !resp.ok) return null;
return resp.json();
}).then(function (data) {
if (!data) return;
var t = data.theme;
if (t === "sombre" || t === "clair" || t === "systeme") {
ecrireCacheTheme(t);
appliquerThemeCommun(t);
}
/* theme null ou inconnu : repli intégral, rien n'est touché. */
}).catch(function () { /* Hub injoignable : repli intégral */ });
}
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -307,6 +477,28 @@
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
/* v0.3.0 : les liens du Hub (menu compte) sont resynchronisés
* eux aussi à l'ouverture d'un menu, pour refléter setUrls et
* la dérivation -dev même s'ils ont été construits avant. */
refreshCompteHrefs();
}
/* Liens du Hub dans le menu compte (v0.3.0) : chemin → libellé.
* « Profil » est le quatrième lien (vers <hub>/profil) ; il
* remplace l'ancien item relayé « profil ». */
var HUB_LINKS = [
["telecharger", "Télécharger"],
["a-propos", "À Propos"],
["aide", "Aides"],
["profil", "Profil"]
];
function hubHref(path) {
return withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) + path;
}
function refreshCompteHrefs() {
if (!compteMenu) return;
compteMenu.querySelectorAll("[data-hub-link]").forEach(function (a) {
a.setAttribute("href", hubHref(a.getAttribute("data-hub-link")));
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
@@ -429,10 +621,80 @@
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
/* Icônes monoline du menu compte (v0.4.0) — glyphes contour
* dans le style des icônes du rail de la console : viewBox 24,
* stroke currentColor, remplissage nul, extrémités rondes.
* Chaque glyphe est une liste de primitives [balise, attributs]
* construites en DOM (jamais d'innerHTML), comme le reste du
* fichier. Le libellé reste le nœud texte de l'item ; l'icône,
* appendue après, est replacée à gauche par le CSS (order: -1
* sur .draco-menu-icone, l'item étant déjà en flex). */
var MENU_ICONES = {
/* Flèche vers le bas dans un plateau. */
"telecharger": [
["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["polyline", { points: "7 10 12 15 17 10" }],
["line", { x1: "12", y1: "15", x2: "12", y2: "3" }]
],
/* Cercle d'information. */
"a-propos": [
["circle", { cx: "12", cy: "12", r: "9" }],
["line", { x1: "12", y1: "11", x2: "12", y2: "16" }],
["line", { x1: "12", y1: "8", x2: "12.01", y2: "8" }]
],
/* Cercle d'interrogation. */
"aide": [
["circle", { cx: "12", cy: "12", r: "9" }],
["path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }]
],
/* Personne : tête + épaules. */
"profil": [
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
["circle", { cx: "12", cy: "7", r: "4" }]
],
/* Flèche sortant d'un cadre (porte). */
"deconnexion": [
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
["polyline", { points: "16 17 21 12 16 7" }],
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
span.appendChild(svg);
return span;
}
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
* d'identité, puis les liens du Hub (Télécharger, À Propos,
* Aides, Profil — vrais <a href>, même patron que le menu des
* applications), un séparateur, puis « Déconnexion » relayée à
* l'hôte. L'en-tête et les href sont rafraîchis à chaque
* ouverture, pour refléter un title posé après coup par l'hôte
* (ex. ObsiGate, qui remplit l'avatar depuis /api/auth/me après
* chargement) et la résolution d'URL la plus récente. */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
@@ -443,15 +705,28 @@
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
HUB_LINKS.forEach(function (pair) {
var a = document.createElement("a");
a.className = "draco-menu-item";
a.href = hubHref(pair[0]);
a.setAttribute("data-hub-link", pair[0]);
a.setAttribute("role", "menuitem");
a.textContent = pair[1];
a.appendChild(menuIcone(pair[0]));
compteMenu.appendChild(a);
});
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
compteMenu.appendChild(sep);
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", "deconnexion");
b.setAttribute("role", "menuitem");
b.textContent = "Déconnexion";
b.appendChild(menuIcone("deconnexion"));
compteMenu.appendChild(b);
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
@@ -459,6 +734,7 @@
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
refreshCompteHrefs();
}
function toggleCompteMenu(force) {
buildCompteMenu();
@@ -550,11 +826,15 @@
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
themePref: null, themeMode: null,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
refreshCompteHrefs();
}
};
@@ -563,6 +843,7 @@
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
initThemeCommun();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+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");
+65
View File
@@ -53,6 +53,55 @@ def test_barre_v020_onglet_shaarit_et_garde_data_theme():
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()
@@ -66,3 +115,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
+19 -1
View File
@@ -12,7 +12,7 @@ import pytest
from app.services import draco_urls
VARS = ("DRACO_SSO_URL", "DRACO_AGENT_URL", "TIKTAK_URL", "KANBANTE_URL",
"SHAARIT_URL", "FLOWDECK_URL", "OAUTH_REDIRECT_URI",
"SHAARIT_URL", "HUB_URL", "FLOWDECK_URL", "OAUTH_REDIRECT_URI",
"AGENT_EVENEMENTS_URL")
@@ -63,6 +63,24 @@ def test_shaarit_defaut_derivation_et_explicite(monkeypatch, avec_hote):
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
+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