Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf030669a0 | ||
|
|
83a81b931e | ||
|
|
23df2bdcbe | ||
|
|
03a1c9f6f5 | ||
|
|
045abd6a3c | ||
|
|
06855937bd | ||
|
|
854ff0f904 | ||
|
|
5a5d7a5c59 | ||
|
|
b8949b2ddc | ||
|
|
15d015e59b | ||
|
|
dcf847e2fd | ||
|
|
a3f76e9b0c | ||
|
|
c157a22668 | ||
|
|
cc3707be7a | ||
|
|
75c6f61235 | ||
|
|
8d7692c6df | ||
|
|
318a20c652 | ||
|
|
006cbeed64 | ||
|
|
92ad47fc99 | ||
|
|
84648591ea | ||
|
|
308f99cf63 | ||
|
|
3bfda066ab | ||
|
|
0230f4fa72 | ||
|
|
9f6f366870 | ||
|
|
44a8154cae | ||
|
|
222cd0b562 | ||
|
|
5127510509 | ||
|
|
3876f0e9c3 | ||
|
|
c8d2eb19db | ||
|
|
2943e40e81 | ||
|
|
128deb248d | ||
|
|
151d9faeab | ||
|
|
c335f57ea2 | ||
|
|
c27fd977a8 | ||
|
|
65d01f91b8 | ||
|
|
0427c00015 | ||
|
|
b83de53447 | ||
|
|
5eba3e2558 | ||
|
|
10aa88e03c | ||
|
|
e0e0f9af0f | ||
|
|
35b828a4c5 | ||
|
|
6ebb3b3eae | ||
|
|
569d0e33a6 | ||
|
|
1d1ce5a848 | ||
|
|
f04369343d | ||
|
|
8290abd675 | ||
|
|
ea60d7e172 | ||
|
|
606d59f483 | ||
|
|
a046f286e6 | ||
|
|
0a92a55efa | ||
|
|
3c383559f4 | ||
|
|
6253c7558a | ||
|
|
6835607f66 | ||
|
|
478749566a | ||
|
|
d00c261ff1 | ||
|
|
646a4977f5 | ||
|
|
8683e1d724 | ||
|
|
5a7f1b9bcf | ||
|
|
14089ff59e | ||
|
|
0b0639ce61 | ||
|
|
603ba976e1 | ||
|
|
ad1cea23ab | ||
|
|
b5b5a0e820 | ||
|
|
60666bf79d | ||
|
|
099ffe7e8f | ||
|
|
bd5507d6ef | ||
|
|
3ba8b7b667 | ||
|
|
52dcbf3607 | ||
|
|
e28a537f79 | ||
|
|
b87ac72d78 | ||
|
|
ca367b6918 | ||
|
|
9037f2dd69 | ||
|
|
3a27fde176 | ||
|
|
d3acae2bd8 | ||
|
|
ad0a715a4c | ||
|
|
f7095f8057 | ||
|
|
67fbda60d3 | ||
|
|
5280b84d12 | ||
|
|
b42a81018c | ||
|
|
5c85763de1 | ||
|
|
2aacb60102 | ||
|
|
c4247b8bbc | ||
|
|
c29bafeccc | ||
|
|
fe4bed91d7 | ||
|
|
3abf908ca4 | ||
|
|
9c8e8f54dc | ||
|
|
1e59781d77 | ||
|
|
ead3393211 | ||
|
|
48beb692dc | ||
|
|
fe4b189697 | ||
|
|
c1e79d07e4 | ||
|
|
5de962b2c6 | ||
|
|
bc150bf225 | ||
|
|
31b8a86861 | ||
|
|
dda60313f0 | ||
|
|
fdf33c0f54 | ||
|
|
92bfd8f0ef | ||
|
|
64973a578b | ||
|
|
fb5414efbb | ||
|
|
174cd1c3e7 | ||
|
|
ee6748c261 | ||
|
|
dd0b12213f | ||
|
|
6195f252d9 | ||
|
|
397a60663c | ||
|
|
d792714f4f | ||
|
|
65edbc9e6c | ||
|
|
95df928035 | ||
|
|
78ac84e1cc | ||
|
|
550bcb5cc8 | ||
|
|
44baa0a273 | ||
|
|
78021d6549 | ||
|
|
72d8ce39d6 | ||
|
|
14dd77094d | ||
|
|
031879889c | ||
|
|
a5d2e0d085 | ||
|
|
8403b4fa08 | ||
|
|
757e32229d | ||
|
|
4c995f4c65 | ||
|
|
c950940e22 | ||
|
|
858dfd22e1 | ||
|
|
ab4a4dbd9d | ||
|
|
0bc1aca9bc | ||
|
|
515727ab9b | ||
|
|
3f375bca2a | ||
|
|
483af904af | ||
|
|
bfe0c75106 | ||
|
|
2a96ad3b1d | ||
|
|
12a29b29f6 | ||
|
|
8feab0188c | ||
|
|
fc44efabbb | ||
|
|
3ed09cbc0f | ||
|
|
a4f27bdbe7 | ||
|
|
05d1903045 | ||
|
|
b5b4a00b75 | ||
|
|
97f997840c | ||
|
|
474851b3bb | ||
|
|
4dfa8fd74c | ||
|
|
e709310e70 | ||
|
|
4d0c8ad934 | ||
|
|
b609e8913c | ||
|
|
f10e3d12e6 | ||
|
|
78c514c78b | ||
|
|
d20ae93b40 | ||
|
|
a885153a68 | ||
|
|
3edeb88c92 | ||
|
|
677b174f09 | ||
|
|
ab3d0798c7 | ||
|
|
332983c4d6 | ||
|
|
2c2f23dc57 | ||
|
|
097accc44c | ||
|
|
7ea5614093 | ||
|
|
e4da00cf17 | ||
|
|
03e8abf8c8 | ||
|
|
6750cdf81a | ||
|
|
ffc70e10b9 | ||
|
|
ed834a44b5 | ||
|
|
66312bba2d | ||
|
|
46572501cf | ||
|
|
b9a570424b | ||
|
|
c6c06a7db9 | ||
|
|
f12f69124f | ||
|
|
504a349224 | ||
|
|
d6549d1b86 | ||
|
|
9c9828e9bd | ||
|
|
5b9fe5944b | ||
|
|
3a7b119b24 | ||
|
|
5ad6b68fef | ||
|
|
37e3ffd586 | ||
|
|
36dacc55da | ||
|
|
3180ef63ce | ||
|
|
bdba956bd2 | ||
|
|
732af9de55 | ||
|
|
564f2a7b11 | ||
|
|
39df53e53a | ||
|
|
c2f3feec7e | ||
|
|
3e042eed2d | ||
|
|
183fd316ef | ||
|
|
58e1c23592 | ||
|
|
3e7e53d2fc | ||
|
|
53b6f27fd2 | ||
|
|
8f5e1f1b0c | ||
|
|
6eb679f234 | ||
|
|
1604245f94 | ||
|
|
5154afed01 | ||
|
|
8c40aba850 | ||
|
|
b9a7824b28 | ||
|
|
883ededa8f | ||
|
|
b9db438034 | ||
|
|
0273990196 | ||
|
|
782d2bea72 | ||
|
|
f08883cb69 | ||
|
|
3150aeecaa | ||
|
|
c9c9a0dd6f | ||
|
|
2283b72b2f | ||
|
|
940a3b4534 | ||
|
|
35a9c7ac80 | ||
|
|
2e8240347f | ||
|
|
d69ff4a6f9 | ||
|
|
4ea4934b3c | ||
|
|
add9252b43 | ||
|
|
45d5745b54 | ||
|
|
c92054fb5c | ||
|
|
2e8d94d0bb | ||
|
|
bfbca9dce4 | ||
|
|
2c69eb71d7 | ||
|
|
c50e0e5d0e | ||
|
|
f2dcf8970e | ||
|
|
e882612249 | ||
|
|
9c9a007419 | ||
|
|
56b70481e8 | ||
|
|
55351bc997 | ||
|
|
33e4d14b81 | ||
|
|
7901c7919d | ||
|
|
3b8402374a | ||
|
|
6635defb2e | ||
|
|
7fb073d462 | ||
|
|
2e59f8786b | ||
|
|
ba6069962a | ||
|
|
375de59e28 | ||
|
|
3b84a86b8f | ||
|
|
a5dac2349d | ||
|
|
da6de52198 | ||
|
|
87016e5581 | ||
|
|
e0e45ac048 | ||
|
|
1b3ba04b5b | ||
|
|
5c5180336b | ||
|
|
73bf863e40 | ||
|
|
06d0e354fe | ||
|
|
a01fba6602 | ||
|
|
54ebf2365c | ||
|
|
24eaa7f9df | ||
|
|
8e3eb3a9d7 | ||
|
|
01ce8b8ec3 | ||
|
|
5dd6809ef0 | ||
|
|
7b8bc52650 | ||
|
|
1758c3b6d2 | ||
|
|
6669633902 | ||
|
|
a65d282c0b | ||
|
|
524b43cc40 | ||
|
|
4e0b27c4d7 | ||
|
|
c6d7acfd99 | ||
|
|
bf941c71ec | ||
|
|
dcec510451 | ||
|
|
cb468b0f3c | ||
|
|
6713510ce9 | ||
|
|
bf5b32de16 | ||
|
|
27e7664ef0 | ||
|
|
ccbf1d3269 |
@@ -47,6 +47,16 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# Ignored directories (séparés par des virgules)
|
||||
# OBSIGATE_IGNORED_DIRS=.obsidian,.trash,.git,__pycache__,node_modules,.obsigate-backup
|
||||
|
||||
# Vaults DIR (DIR_N_NAME / DIR_N_PATH dans le compose) — options par vault :
|
||||
# DIR_N_READONLY=true : lecture seule déclarée (mutations refusées
|
||||
# en 403, actions d'écriture masquées dans l'UI)
|
||||
# DIR_N_IGNORED_DIRS=.home,.venv : noms de dossiers cachés pour CE vault
|
||||
# (toute profondeur, en plus du global)
|
||||
# DIR_N_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env
|
||||
# : motifs fnmatch sur le chemin relatif dans
|
||||
# le vault (un dossier qui matche cache son
|
||||
# sous-arbre) — navigation, lecture, index
|
||||
|
||||
# Audit
|
||||
# OBSIGATE_AUDIT_MAX_SIZE=10485760
|
||||
|
||||
@@ -119,6 +129,8 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
|
||||
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
|
||||
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
|
||||
# DRACO_SHAARIT_URL=https://shaarit.dracodev.net
|
||||
# DRACO_HUB_URL=https://hub.dracodev.net
|
||||
# DRACO_GITEA_URL=https://git.dracodev.net
|
||||
|
||||
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
jobs:
|
||||
# ── Lint ──────────────────────────────────────────────────────────
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
test:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
|
||||
# ── Security scan ─────────────────────────────────────────────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
# ── E2E Tests (Playwright) ─────────────────────────────────────────
|
||||
e2e:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -272,7 +272,13 @@ jobs:
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
docker create --name obsigate-e2e -p 2029:8080 \
|
||||
# DinD robuste : PAS de port publié (les ports 2029 fuités par des
|
||||
# runs antérieurs font échouer « docker start », et l'IP passerelle
|
||||
# du job n'est pas toujours joignable → boucle de health curls sans
|
||||
# timeout qui pendaient 150 s chacun). À la place, l'app est
|
||||
# rattachée au réseau du job et jointable par nom DNS embarqué.
|
||||
JOBNET=$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}')
|
||||
docker create --name obsigate-e2e \
|
||||
-v $(pwd)/test_vault:/vaults/TestVault \
|
||||
-v $(pwd)/test_dir:/vaults/TestDir \
|
||||
-e VAULT_1_NAME=TestVault \
|
||||
@@ -288,17 +294,22 @@ jobs:
|
||||
# fixtures avant le démarrage (l'indexeur scanne au démarrage).
|
||||
docker cp test_vault/. obsigate-e2e:/vaults/TestVault
|
||||
docker cp test_dir/. obsigate-e2e:/vaults/TestDir
|
||||
if [ -n "$JOBNET" ]; then docker network connect "$JOBNET" obsigate-e2e || true; fi
|
||||
docker start obsigate-e2e
|
||||
# Le port publié est joignable via l'IP de la passerelle (localhost
|
||||
# du job container ne voit pas le port publié sur l'hôte).
|
||||
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
|
||||
echo "Gateway IP: $GW"
|
||||
# Wait for health check
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://$GW:2029/api/health"; then echo "Health OK"; break; fi
|
||||
if [ -n "$JOBNET" ]; then
|
||||
APP_URL="http://obsigate-e2e:8080"
|
||||
else
|
||||
echo "Réseau du job introuvable (JOBNET vide) — impossible de localiser l'app sans port publié"
|
||||
exit 1
|
||||
fi
|
||||
echo "APP_URL: $APP_URL"
|
||||
# --max-time : ne JAMAIS pendre sans timeout (bug d'origine : curls
|
||||
# sans timeout = 150 s × 31 tentatives, job « bloqué »).
|
||||
for i in $(seq 1 40); do
|
||||
if curl -sf --max-time 3 "$APP_URL/api/health"; then echo "Health OK"; break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf "http://$GW:2029/api/health" >/dev/null || { echo "App not reachable at $GW:2029"; exit 1; }
|
||||
curl -sf --max-time 5 "$APP_URL/api/health" >/dev/null || { echo "App not reachable at $APP_URL"; docker logs --tail 40 obsigate-e2e; exit 1; }
|
||||
# Les fixtures sont copiées via `docker cp` en root → rendre le vault
|
||||
# inscriptible par l'utilisateur non-root de l'app (UID 1000), sinon
|
||||
# toute création/édition de fichier renvoie 403 « Vault is read-only ».
|
||||
@@ -306,9 +317,10 @@ jobs:
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
|
||||
echo "Using BASE_URL=http://$GW:2029"
|
||||
BASE_URL="http://$GW:2029" npx playwright test --project=chromium-desktop --reporter=list
|
||||
BASE_URL="http://obsigate-e2e:8080"
|
||||
docker exec obsigate-e2e true >/dev/null 2>&1 || { echo "obsigate-e2e n'est pas en marche"; exit 1; }
|
||||
echo "Using BASE_URL=$BASE_URL"
|
||||
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop --reporter=list
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
|
||||
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.81.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.86.3**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,172 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.86.3] — 2026-11-08
|
||||
|
||||
- **BUG-125 — Aller-retour SSO : le pont de l'application sœur reçoit à nouveau le cookie `draco_session` quand les cookies ont disparu sous une session `sessionStorage` encore « valide »** (suite du chantier « entrée SSO », retest réel de Bruno le 2026-11-08 après le correctif BUG-123 et celui du retour sur le bon hôte) : le retour tombait bien sur l'application demanderesse, mais son pont ne voyait **aucun jeton** et déposait l'utilisateur devant le formulaire, sans message. Chaîne fautive : cookies `access_token` et `draco_session` absents alors que la session semble valide en `sessionStorage` → le chemin court d'`initAuth()` (restauration sans échange serveur, BUG-123) honore le `return_to` immédiatement — le middleware de renouvellement (`DracoRenewalMiddleware`) n'a aucune requête authentifiée à traverser, donc aucune occasion de réémettre le cookie draco manquant. Correctif (`frontend/js/auth.js`) : quand la page est l'arrivée d'un `/sso/authorize` (`return_to` présent), le chemin court est écarté et `initAuth()` passe par le refresh silencieux (cookie refresh, 7 j) suivi de `/api/auth/me` — la réponse traverse le middleware, qui réémet le cookie draco absent ou invalide **avant** que le retour soit honoré ; si le refresh échoue, l'écran de connexion s'affiche comme avant (le `return_to` reste dans l'URL, `saveToken` l'honorera après login frais). Hors arrivée `authorize`, le démarrage est inchangé. Garde : `tests/test_draco_barre.py` (`test_retour_sso_refresh_force_bug_125`) ; variante fautive reproduite puis vérifiée en navigateur réel dans la pile « conteneur dev réel » (requête du pont journalisée : sans cookie draco avant correctif, avec cookie et session ouverte après). Fiche : `docs/features/retour-sso-cookie-draco-bug-125.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.86.2] — 2026-11-08
|
||||
|
||||
- **BUG-123 — Aller-retour SSO refermé aussi quand la session ObsiGate existe déjà** (chantier « entrée SSO de bout en bout », écart constaté par Bruno en test réel le 2026-11-08) : `GET /sso/authorize?return_to=…` redirige vers `/?return_to=…`, mais le retour n'était honoré que par `AuthManager.saveToken()` (login frais, MFA, WebAuthn). Pour un utilisateur déjà connecté chez ObsiGate, `initAuth()` restaurait la session — jeton encore valide ou refresh silencieux — et affichait l'accueil sans jamais suivre le `return_to` : l'utilisateur restait chez ObsiGate, et l'application sœur ne recevait jamais son pont (aucune session locale). Correctif (`frontend/js/auth.js`) : `_honorReturnTo()` signale désormais la redirection (valeur de retour), et les deux chemins de restauration d'`initAuth()` l'invoquent avant d'afficher l'application. Les refresh silencieux **en cours de session** ne sont pas touchés : ils ne doivent jamais rediriger. Même liste blanche qu'avant (https + `*.dracodev.net`, jamais l'hôte courant). Garde : `tests/test_draco_barre.py` (`test_barre_v070_setuser_et_retour_sso_restauration`) ; chaîne complète prouvée en navigateur réel dans une pile locale aux noms publics (dossier `preuve-entree-sso`). Fiche : `docs/features/entree-sso-bug-123-124.md`.
|
||||
- **BUG-124 — Le nom du compte rejoint la barre commune dès le login, sans rechargement** (même chantier) : après un login dans la SPA, le menu compte de la barre affichait l'en-tête générique « Compte » et l'avatar neutre « ✦ » jusqu'au rechargement forcé — `renderUserSection()` poussait le rôle (`setAdmin`) mais jamais le nom, et le seul poseur d'identité était le fetch d'amorçage d'`index.html`, limité aux sessions déjà présentes au chargement. Barre commune re-vendangée **verbatim** en **v0.7.0** depuis dracodev-commons (PR commons #15) : nouvelle API `DRACO_BAR.setUser(user)` qui resynchronise l'avatar (initiale, infobulle) et l'en-tête du menu compte immédiatement. Branchement hôte : `renderUserSection()` appelle `setUser(name)` pour toute session établie (et `setUser(null)` sans identité), le fetch d'amorçage d'`index.html` également. Garde : `tests/test_draco_barre.py` (même test).
|
||||
|
||||
---
|
||||
|
||||
## [2.86.1] — 2026-11-06
|
||||
|
||||
- **Barre commune v0.6.0 — entrée « Administration » du menu compte** (fusion #142, demande de Bruno) : barre re-vendangée **verbatim** depuis dracodev-commons (PR commons #14) — `frontend/draco-barre.js` et `frontend/draco-barre.css`. Le menu compte gagne un cinquième lien Hub, « Administration » → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), après le séparateur et avant « Déconnexion », à **visibilité conditionnelle** : l'entrée n'apparaît que si l'hôte affirme que le compte courant est administrateur de suite (`DRACO_BAR.setAdmin`). Branchement hôte : `frontend/index.html` affirme le statut depuis la réponse de `/api/auth/me` déjà chargée pour l'avatar — `setAdmin(u.role === "admin")`. Le rôle global du compte chez ObsiGate est la définition même de l'administrateur de suite (c'est lui que la garde `/admin/*` du Hub vérifie, via ce même endpoint) : ObsiGate le connaît nativement. Tout autre rôle, ou échec de l'appel : aucune affirmation, l'entrée reste masquée. Garde : `tests/test_draco_barre.py` (`test_barre_v060_entree_administration`).
|
||||
|
||||
---
|
||||
|
||||
## [2.86.0] — 2026-11-06
|
||||
|
||||
- **BUG-122 — Déconnexion globale navigable : `GET /sso/logout`** (fusion #141) : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien dans les applications sœurs — la barre relaie l'action à l'hôte, et la déconnexion globale d'ObsiGate (ACC-3.1, #216) n'était appelable qu'en same-origin depuis l'interface d'ObsiGate (`POST /api/auth/logout`). Nouvelle route `GET /sso/logout?return_to=`, symétrique de `/sso/authorize` : elle exécute la déconnexion globale — logique extraite dans `apply_global_logout()` (`backend/auth/router.py`), désormais partagée par les deux entrées (jetons natifs refresh/access révoqués, `jti` du cookie `draco_session` révoqué, cookie effacé sur le domaine parent) — puis redirige vers le `return_to` validé par la liste blanche existante (`validate_return_to` : https + `*.dracodev.net`, repli « / »). Idempotente sans session. Les applications sœurs branchent leur action `deconnexion` sur cette route (leur session locale est détruite d'abord chez celles qui en ont une) — et la barre d'ObsiGate elle-même aussi (`frontend/index.html`, retour à sa racine). Tests : `tests/test_draco_session.py` (+3 : révocation et redirection, liste blanche, idempotence), `tests/test_draco_barre.py` (+1 : branchement de la barre d'ObsiGate). Fiche : `docs/features/deconnexion-globale-bug-122.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.85.1] — 2026-11-06
|
||||
|
||||
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
|
||||
## [2.85.0] — 2026-11-06
|
||||
|
||||
- **HUB-22 — API machine des sessions draco (vague C du plan Hub v2.1, #227)** : trois nouvelles routes sous la garde machine existante (`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / `X-Draco-Actor` ; 401 sans clé valide, 404 si l'API n'est pas configurée ou le compte inconnu) — `GET /api/admin/machine/comptes/{username}/sessions` liste les sessions actives du compte (`jti`, `exp`, `expire_le` en ISO 8601 UTC, `revoquee`), `POST …/sessions/{jti}/revoquer` révoque une session (404 si le `jti` n'est pas dans l'ensemble actif de ce compte — jamais de révocation cross-utilisateur) et `POST …/sessions/revoquer-toutes` révoque toutes les sessions (déconnexion globale, **sans désactiver le compte**). Backend : `draco_session.issued_sessions_for_user()` (sessions actives triées par expiration, purge des expirés au passage) et `revoke_one_for_user()` (appartenance au `sub` vérifiée avant révocation), sur le registre des émissions de HUB-13 et la même clé d'identité (`id`, à défaut `username`). Écritures journalisées dans `data/audit.log` (`session_revoquee`, `sessions_revoquees`). Fiche : `docs/features/hub-sessions-machine-227.md`. Tests : `tests/test_comptes_machine.py` (`TestSessions`), `tests/test_draco_session.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.84.1] — 2026-11-06
|
||||
|
||||
- **HUB-18 — Thème commun (fusion #132)** : barre commune re-vendangée **verbatim** en **v0.5.0** depuis dracodev-commons (branche `hub18-theme-commun`, PR commons #12) — `frontend/draco-barre.js` seul change côté barre (CSS et fragment inchangés). 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 à ObsiGate dans `frontend/app.js` : `initTheme()` écoute l'événement `draco:theme` de la barre et route le mode effectif par `applyTheme()` — icône et libellé du bouton de thème et feuilles highlight.js restent synchronisés, le mode appliqué devient le choix local mémorisé (`obsigate-theme`), et le bouton de bascule reste fonctionnel pour la session. Garde ajoutée à `tests/test_draco_barre.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.84.0] — 2026-11-06
|
||||
|
||||
- **HUB-13 — API d'administration des comptes, machine-à-machine (fusion #127, #226)** : nouvelles routes `/api/admin/machine/comptes` (liste, profil étendu, création, modification, désactivation/réactivation, suppression, réinitialisation du mot de passe et du MFA), protégées par la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, distincte de `DRACO_SSO_MACHINE_KEY` ; aucune session utilisateur ne les ouvre ; API inerte si la clé n'est pas configurée). Le profil étendu expose nom d'utilisateur, nom affiché, langue, avatar (valeur + présence), rôle global, état actif, MFA activé oui/non — jamais le secret TOTP ni le haché du mot de passe. La désactivation arrête l'émission de nouveaux jetons et révoque les `jti` draco actifs : `draco_session` tient désormais un registre des émissions (`data/draco_issued.json`) et `revoke_all_for_user()` révoque via la liste existante ; le repère `password_changed_at` (BUG-028) est aussi avancé. Nouveau champ utilisateur `must_change_password` : posé à la création machine et à la réinitialisation du mot de passe, signalé par la connexion et `GET /api/auth/me`, effacé au changement effectif. Chaque action est journalisée dans `data/audit.log` (acteur = identité de service `X-Draco-Actor`, action, compte cible, horodatage). Fiche : `docs/features/hub-api-admin-comptes-226.md`. Tests : `tests/test_comptes_machine.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.5] — 2026-11-05
|
||||
|
||||
- **Barre commune v0.4.1 — correctif des icônes invisibles du menu compte (fusion #125)** : seul `frontend/draco-barre.js` change, re-vendangé **verbatim** depuis dracodev-commons (branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11) ; le CSS est inchangé. 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. Garde ajoutée à `tests/test_draco_barre.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.4] — 2026-11-05
|
||||
|
||||
- **Barre commune v0.4.0 — icônes monoline du menu compte (HUB-11, fusion #125)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.4.0 (branche `menu-compte-icones-v0-4-0`, PR commons #10). Les items du menu compte — **Télécharger**, **À Propos**, **Aides**, **Profil**, **Déconnexion** — reçoivent des **icônes SVG monoline** (contour, trait fin, `currentColor`, ~18 px) dans le style des icônes du rail de la console Spark ; aucun emoji. L'en-tête d'identité reste sans icône ; les liens Hub et le relais « Déconnexion » sont inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.3] — 2026-11-04
|
||||
|
||||
- **Barre commune v0.3.0 — liens Hub du menu compte (HUB-1, fusion #115)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.3.0 (branche `hub-menu-compte-v0-3-0`, PR commons #9). Le menu compte gagne les liens **Télécharger**, **À Propos**, **Aides** et **Profil** (devenu un lien, plus un relais) vers le Hub. Branchement hôte : `backend/draco_config.py` gagne la clé `hub` (défaut officiel `https://hub.dracodev.net`, variable `DRACO_HUB_URL`, dérivation `-dev` → `hub-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` ; `frontend/index.html` la passe en `urls.hub` de `DRACO_BAR_CONFIG`. « Déconnexion » reste relayée, inchangée. Aucun nouvel onglet : le Hub n'apparaît que dans le menu compte.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.2] — 2026-11-04
|
||||
|
||||
- **Recherche fédérée — ShaarIt, 5e source de « Rechercher partout »** (demande de Bruno, 2026-11-04 ; contrat dracodev-commons v0.2.1, `contrats/recherche-federee.md`) : `frontend/js/recherche.js` **re-vendangé verbatim** depuis la source canonique de commons (`ui/recherche/`) — ShaarIt devient une source de la page `/recherche` comme les autres (bases prod/dev, onglet, normaliseur : lien profond canonique `{base}shaare/<shorturl>`, contexte « Bookmark · privé · tags », repli recherche native `/?q=`). Le fichier reste strictement identique chez les cinq hôtes.
|
||||
- **Page `/recherche`** : onglet et section « ShaarIt — bookmarks », `data-base-shaarit` rendu par le jeton `{{URL_SHAARIT}}` (déjà résolu par la route depuis la 2.83.1) ; la CSP `connect-src` de la page ajoute l'origine ShaarIt résolue (`backend/main.py`).
|
||||
- **CORS de recherche** (`backend/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` d'ObsiGate devient lisible depuis les pages de ShaarIt, et réciproquement.
|
||||
- Tests : `tests/frontend/recherche.test.mjs` porté à 5 sections (normaliseur ShaarIt inclus) ; `tests/test_recherche_cors.py` — allowlist attendue à 12 origines.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.1] — 2026-11-03
|
||||
|
||||
- **Barre commune (dracodev-commons v0.2.0 vendangée, @70c837a)** : nouvel onglet **ShaarIt** (bookmarks, réécriture Rust de Shaarli) après Kanbante et avant l'onglet Console Spark — `frontend/draco-barre.css` et `frontend/draco-barre.js` remplacés **verbatim** par la v0.2.0 (hachages vérifiés), logo `frontend/img/logos/shaarit.svg` ajouté tel quel ; le fragment porté dans `frontend/index.html` et `frontend/recherche.html` (jeton `{{URL_SHAARIT}}` remplacé par la route `/recherche`). L'URL ShaarIt suit le **même mécanisme** que les autres apps : `backend/draco_config.py` gagne la clé `shaarit` (défaut officiel `https://shaarit.dracodev.net`, variable `DRACO_SHAARIT_URL`, dérivation `-dev` → `shaarit-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` et réécrite sur l'onglet comme les autres.
|
||||
- **⚠️ Changement de comportement visible — garde `data-theme` (CSS v0.2.0)** : les blocs `prefers-color-scheme` du CSS vendangé sont désormais préfixés `html:not([data-theme])`. La copie précédente n'avait **pas** cette garde. ObsiGate pose toujours `data-theme` sur `<html>`, donc sa barre ne change pas en pratique ; la garde ne s'exercerait que sur une page hôte sans `data-theme` (elle ne forcerait plus le sombre système contre un choix clair). Signalé pour arbitrage à la fusion, comme chez Flowdeck où le piège était actif.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.0] — 2026-11-03
|
||||
|
||||
- **Vaults DIR : exclusions par vault et lecture seule déclarable** (prépare le vault « Spark ») : trois nouvelles variables par vault DIR — `DIR_N_READONLY=true` (les mutations sont refusées en 403 « read_only » et l'arborescence n'affiche plus les actions d'écriture pour ce vault), `DIR_N_IGNORED_DIRS=.home,.venv,…` (noms de dossiers cachés à toute profondeur, en plus du global `IGNORED_DIRS`) et `DIR_N_IGNORED_PATHS=Executeurs/*/data,…` (motifs fnmatch sur le chemin relatif ; un dossier qui matche cache tout son sous-arbre). Les exclusions s'appliquent désormais **partout** — navigation (`/api/browse`), lecture (`/api/file`, `raw`, téléchargement : 404), liste des chemins et indexation — et plus seulement à l'indexeur. `/api/vaults` expose `readonly` pour chaque vault. Sans ces variables, comportement inchangé. Fiche : `docs/features/spark-vault-dir.md`. Tests : `tests/test_spark_vault_dir.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.82.8] — 2026-10-30
|
||||
|
||||
- **Panneau détail d'un secret : Afficher/Masquer + Copier la valeur** (PR #57) : la valeur reste masquée (••••••) par défaut ; Afficher la révèle en place, Masquer l'efface (effacée aussi dès que le détail change). « Copier la valeur » copie au presse-papiers sans afficher. Le bouton « Copier » existant ne copiait que la référence.
|
||||
- **Nouvel endpoint `POST /api/coffres/{slug}/secrets/{nom}/reveler`** : session + gestion du coffre exigées (propriétaire, ou admin sur coffre d'instance) ; un lecteur par octroi utilisateur reçoit 403. Chaque révélation est auditée (`revelation_manuelle`) et met à jour la dernière utilisation. Ce n'est pas une distribution : aucun octroi créé, aucun consommateur ne reçoit la valeur.
|
||||
- **Suppression de la « Copie manuelle à usage unique »** par consommateur temporaire : elle échouait systématiquement (aucun octroi pour le consommateur temporaire, donc 403 octroi_absent) ; « Copier la valeur » la remplace.
|
||||
- **Tests** : `tests/test_coffre_secrets.py` 44/44 (5 nouveaux TestRevelation).
|
||||
|
||||
## [2.82.7] — 2026-10-29
|
||||
|
||||
- **Barre commune (dracodev-commons v0.1.11 vendangée)** : la marque de la barre n'est plus le logo rouge — c'est l'étoile Spark monochrome (`frontend/img/brand-spark.png`, servi en `/static/img/brand-spark.png`, 26 px), blanche sur barre foncée et noire sur barre claire : ObsiGate pose déjà `data-theme` sur `<html>` (sombre par défaut, mode persisté), donc la règle vendangée `[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }` s'applique directement et aucune surcharge propre n'est nécessaire. L'onglet Console Spark (et son entrée de menu, qui le clone) GARDE le logo rouge. Le bouton avatar Spark flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px, `var(--agent)`), via le CSS vendangé.
|
||||
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.11 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.11, `frontend/draco-barre.js` inchangé (identique à commons, lui-même inchangé en v0.1.11). Le reste de l'intégration est inchangé. Précision : sur `/recherche`, copie statique, le bouton flottant y est visuel seulement, comme au lot précédent — l'ouverture du panneau vit dans la SPA.
|
||||
|
||||
## [2.82.6] — 2026-10-29
|
||||
|
||||
- **Barre commune (dracodev-commons v0.1.10 vendangée)** : le rose Spark `#c2437e` disparaît de la barre — `--agent` passe au rouge Spark `#c00000` (échantillonné dans le logo Spark), l'onglet Console Spark passe à `--app #c00000`, et la marque ✦ devient le logo Spark (26 px). Le bouton « Spark » rose de la barre est supprimé, remplacé par le bouton avatar Spark flottant bas-centre (cercle personnage, `frontend/img/avatar-spark.png`, servi en `/static/img/avatar-spark.png`, `data-act="spark"` → panneau Spark de l'hôte, comme l'ancien bouton). Le bouton Alertes n'affiche plus que la cloche (pastille conservée), donc une barre plus étroite.
|
||||
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.10 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.10, `frontend/draco-barre.js` inchangé (identique à commons v0.1.9, lui-même inchangé en v0.1.10). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé. Précision : sur `/recherche`, copie statique, le panneau Spark n'est pas rejoué (comme avant) — le bouton flottant y est visuel, l'ouverture du panneau vit dans la SPA.
|
||||
|
||||
## [2.82.5] — 2026-10-29
|
||||
|
||||
- **Barre commune (dracodev-commons v0.1.9 vendangée)** : les 5 tuiles des onglets — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers dans `frontend/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px), servis en `/static/img/logos/` ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent (`draco-barre.js`) ; onglet agent passé à `--app #d21f2c`. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (marque ✦, bouton Spark et rappel d'en-tête inchangés).
|
||||
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.9, `draco-barre.js` et `draco-barre.css` remplacés verbatim (les fichiers précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale perdue). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé.
|
||||
|
||||
## [2.82.4] — 2026-10-28
|
||||
|
||||
- **Recherche (PR #52)** : texte parasite du mécanisme de thème retiré du haut de la page `/recherche` (lignes 28-43 de `frontend/recherche.html`, hors commentaire).
|
||||
- **Démarrage (PR #53)** : écran de chargement plein écran `#boot-splash` retiré au démarrage d'ObsiGate (CSS, div et script de pilotage), sans écran de remplacement.
|
||||
|
||||
## [2.82.3] — 2026-10-28
|
||||
|
||||
- **Recherche (PR #49)** : la page `/recherche` applique désormais le thème ObsiGate.
|
||||
- **CORS recherche (PR #51)** : la recherche ObsiGate autorise désormais la console Spark (agent prod+dev) en inter-origines.
|
||||
|
||||
## [2.82.2] — 2026-10-28
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
|
||||
|
||||
## [2.82.1] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **CORS recherche/alertes (#44)** : allowlist explicite des 8 origines sœurs prod+dev, avec credentials, jamais `*` — chaque serveur n'autorisait que l'environnement résolu (prod OU dev, jamais les deux).
|
||||
- **Recherche partout (#46)** : page `/recherche` réintégrée dans le gabarit applicatif, focus automatique, « Tout voir » restant sur la page, onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark.
|
||||
- **Alertes unifiées (#47)** : onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark / Historique (actives sauf Historique = fermées), filtre texte, composant partagé, bouton Console Spark en 5e onglet de l'en-tête après Kanbante.
|
||||
|
||||
---
|
||||
|
||||
## [2.82.0] — 2026-10-28
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Coffre : secrets distribuables (#43)** : nouvel endpoint `GET /api/coffres/distribuables` — authentifié par jeton consommateur (la même vérification que `/distribuer`), il renvoie les métadonnées seules des secrets pour lesquels ce consommateur a un octroi actif (coffre actif, secret non expiré, octroi ni révoqué ni expiré, octrois coffre-wide inclus). Un consommateur sans octroi reçoit une liste vide. Jamais de valeur dans la réponse. Consommateur : la console Spark (agent-commun #14 / PR #15), pour la liste déroulante des clés de fournisseurs LLM.
|
||||
|
||||
## [2.81.4] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CSP connect-src sœurs.
|
||||
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), CORS bornée à `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
|
||||
|
||||
## [2.81.3] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Barre d'apps** — vendange de `dracodev-commons` v0.1.6 : dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||
|
||||
---
|
||||
|
||||
## [2.81.2] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Panneau Spark / console (PR #35)** — style console du panneau, statut « Connecté » intégré au sous-titre de l'en-tête (barre séparée supprimée), texte sous la zone de saisie supprimé, composer identique à la console (pilule, pièces jointes 4 max, textarea auto-extensible, micro dictée, envoi rond bleu), resynchronisation du domaine à chaque ouverture / changement de stockage / retour console, URL console avec `?domain=` ; la console honore `?domain=` valide (persisté avant chargement des discussions, PR agent-commun #7).
|
||||
|
||||
---
|
||||
|
||||
## [2.81.1] — 2026-10-28
|
||||
|
||||
---
|
||||
|
||||
## [2.81.0] — 2026-10-28
|
||||
|
||||
---
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.86.3).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.86.3 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.86.3).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.86.3 | Last updated: September 2026*
|
||||
|
||||
@@ -154,6 +154,35 @@ def log_coffre_event(
|
||||
_write_entry(entry)
|
||||
|
||||
|
||||
def log_compte_admin(
|
||||
acteur: str,
|
||||
action: str,
|
||||
cible: str,
|
||||
ip: str | None = None,
|
||||
details: dict | None = None,
|
||||
):
|
||||
"""Journal d'une action d'administration de compte (HUB-13, fusion #127).
|
||||
|
||||
``acteur`` est l'identité de service appelante (API machine des
|
||||
comptes, en-tête ``X-Draco-Actor``), ``cible`` le compte visé.
|
||||
Règle absolue : ``details`` ne contient jamais de mot de passe, de
|
||||
secret TOTP ni de jeton — seulement des métadonnées (champs
|
||||
modifiés, nombre de jti révoqués…). Le champ ``username`` reprend
|
||||
l'acteur pour rester filtrable par l'écran d'audit existant.
|
||||
"""
|
||||
entry: dict = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"username": acteur,
|
||||
"acteur": acteur,
|
||||
"cible": cible,
|
||||
"ip": ip or "unknown",
|
||||
}
|
||||
if details:
|
||||
entry["details"] = details
|
||||
_write_entry(entry)
|
||||
|
||||
|
||||
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||
"""Read the most recent audit log entries.
|
||||
|
||||
|
||||
@@ -141,9 +141,26 @@ def _draco_fallback(request: Request) -> dict | None:
|
||||
return user
|
||||
|
||||
|
||||
def require_auth(current_user=Depends(get_current_user)):
|
||||
def require_auth(request: Request, current_user=Depends(get_current_user)):
|
||||
"""Dependency: require a valid authenticated user."""
|
||||
if not current_user:
|
||||
# HUB-19 (#228) : la session draco a été refusée parce que le
|
||||
# registre des rôles du Hub attribue le niveau « aucun » à ce
|
||||
# compte pour ObsiGate — réponse explicite, distincte du 401
|
||||
# générique. L'authentification native n'est pas concernée :
|
||||
# ce refus ne naît que sur le chemin draco (user_for_draco).
|
||||
if getattr(request.state, "role_hub_refuse", None) == "aucun":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Accès refusé : le registre des rôles du Hub attribue "
|
||||
"le niveau « aucun » à votre compte pour ObsiGate, "
|
||||
"l'accès par la session draco est donc refusé. "
|
||||
"L'administrateur de la suite peut modifier ce niveau "
|
||||
"dans la matrice des rôles du Hub. L'accès natif "
|
||||
"d'ObsiGate (son propre identifiant) reste possible."
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentification requise",
|
||||
@@ -159,6 +176,31 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin_hub(current_user=Depends(require_admin)):
|
||||
"""Dependency: admin + garde du registre des rôles du Hub (HUB-19, #228).
|
||||
|
||||
Pour les routes d'administration des comptes (``/api/auth/admin/*``) :
|
||||
quand la requête est authentifiée par le **canal draco** (``_draco_sub``
|
||||
présent) et que le rôle du Hub est **connu** (``_hub_role`` non None),
|
||||
le niveau ``administrateur`` du registre est exigé — le rôle local ne
|
||||
suffit plus. Session native, ou rôle inconnu (Hub injoignable) :
|
||||
règles actuelles inchangées.
|
||||
"""
|
||||
if current_user.get("_draco_sub"):
|
||||
role_hub = current_user.get("_hub_role")
|
||||
if role_hub is not None and role_hub != "administrateur":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Accès réservé au niveau « administrateur » d'ObsiGate "
|
||||
"dans le registre des rôles du Hub (votre niveau actuel : "
|
||||
f"« {role_hub} »). L'administrateur de la suite peut "
|
||||
"l'accorder dans la matrice des rôles du Hub."
|
||||
),
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
@@ -40,7 +40,7 @@ from .mfa import (
|
||||
verify_recovery_code,
|
||||
verify_totp,
|
||||
)
|
||||
from .middleware import is_auth_enabled, require_admin, require_auth
|
||||
from .middleware import is_auth_enabled, require_admin_hub, require_auth
|
||||
from .password import hash_password, validate_password_strength, verify_password
|
||||
from .user_store import (
|
||||
create_user,
|
||||
@@ -298,6 +298,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
"role": user["role"],
|
||||
"vaults": user["vaults"],
|
||||
"avatar": user.get("avatar"),
|
||||
# HUB-13 : mot de passe temporaire — changement forcé signalé.
|
||||
"must_change_password": bool(user.get("must_change_password")),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -373,12 +375,15 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Logout: revoke refresh and access tokens, then delete cookies."""
|
||||
def apply_global_logout(request: Request, response: Response) -> None:
|
||||
"""Déconnexion globale (Fusion ACC-3.1, #216) appliquée à ``response``.
|
||||
|
||||
Révoque les jetons natifs (refresh + access, BUG-027), révoque le
|
||||
``jti`` du cookie partagé ``draco_session`` et efface les cookies.
|
||||
Partagée entre ``POST /api/auth/logout`` (réponse JSON) et
|
||||
``GET /sso/logout`` (redirection, BUG-122) : les deux entrées
|
||||
terminent exactement la même session, celle de la suite entière.
|
||||
"""
|
||||
refresh_tok = request.cookies.get("refresh_token")
|
||||
if refresh_tok:
|
||||
payload = decode_token(refresh_tok)
|
||||
@@ -419,6 +424,15 @@ async def logout(
|
||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("access_token", path="/api") # just in case
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Logout: revoke refresh and access tokens, then delete cookies."""
|
||||
apply_global_logout(request, response)
|
||||
return {"message": "Deconnecte avec succes"}
|
||||
|
||||
|
||||
@@ -433,6 +447,8 @@ async def get_me(current_user=Depends(require_auth)):
|
||||
"language": current_user.get("language", "fr"),
|
||||
"last_login": current_user.get("last_login"),
|
||||
"avatar": current_user.get("avatar"),
|
||||
# HUB-13 : mot de passe temporaire — changement forcé signalé.
|
||||
"must_change_password": bool(current_user.get("must_change_password")),
|
||||
}
|
||||
|
||||
|
||||
@@ -487,7 +503,11 @@ async def change_password(
|
||||
assert user is not None, f"User {current_user['username']} not found"
|
||||
if not verify_password(req.current_password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe actuel incorrect")
|
||||
update_user(current_user["username"], {"password": req.new_password})
|
||||
# HUB-13 : le changement effectif efface le marqueur de changement forcé.
|
||||
update_user(current_user["username"], {
|
||||
"password": req.new_password,
|
||||
"must_change_password": False,
|
||||
})
|
||||
updated = get_user(current_user["username"])
|
||||
result: dict = {"message": "Mot de passe mis à jour"}
|
||||
if updated is not None:
|
||||
@@ -949,9 +969,13 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
# Garde `require_admin_hub` (HUB-19, #228) : `require_admin` + — pour
|
||||
# une requête authentifiée par le canal draco dont le rôle du Hub est
|
||||
# connu — exigence du niveau « administrateur » du registre des rôles.
|
||||
# Sessions natives et rôle inconnu : règles actuelles inchangées.
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def list_users(admin=Depends(require_admin)):
|
||||
async def list_users(admin=Depends(require_admin_hub)):
|
||||
"""List all users (admin only). Password hashes are never included."""
|
||||
return get_all_users()
|
||||
|
||||
@@ -959,7 +983,7 @@ async def list_users(admin=Depends(require_admin)):
|
||||
@router.post("/admin/users")
|
||||
async def create_user_endpoint(
|
||||
req: CreateUserRequest,
|
||||
admin=Depends(require_admin),
|
||||
admin=Depends(require_admin_hub),
|
||||
):
|
||||
"""Create a new user (admin only)."""
|
||||
try:
|
||||
@@ -986,7 +1010,7 @@ async def create_user_endpoint(
|
||||
async def update_user_endpoint(
|
||||
username: str,
|
||||
req: UpdateUserRequest,
|
||||
admin=Depends(require_admin),
|
||||
admin=Depends(require_admin_hub),
|
||||
):
|
||||
"""Update a user (admin only)."""
|
||||
updates = req.dict(exclude_none=True)
|
||||
@@ -999,7 +1023,7 @@ async def update_user_endpoint(
|
||||
@router.delete("/admin/users/{username}")
|
||||
async def delete_user_endpoint(
|
||||
username: str,
|
||||
admin=Depends(require_admin),
|
||||
admin=Depends(require_admin_hub),
|
||||
):
|
||||
"""Delete a user (admin only). Cannot delete own account."""
|
||||
if username == admin["username"]:
|
||||
|
||||
@@ -80,8 +80,16 @@ def create_user(
|
||||
role: str = "user",
|
||||
vaults: list[str] | None = None,
|
||||
display_name: str | None = None,
|
||||
must_change_password: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new user. Raises ValueError if username already taken."""
|
||||
"""Create a new user. Raises ValueError if username already taken.
|
||||
|
||||
``must_change_password`` (HUB-13) marque un mot de passe temporaire
|
||||
(création/réinitialisation par l'administration machine) : le
|
||||
changement est forcé à la première connexion. Les comptes créés
|
||||
avant ce champ n'en portent pas — ils valent ``False`` partout où
|
||||
le champ est lu via ``.get(..., False)``.
|
||||
"""
|
||||
with _users_lock:
|
||||
data = _read()
|
||||
if username in data["users"]:
|
||||
@@ -95,6 +103,7 @@ def create_user(
|
||||
"role": role,
|
||||
"vaults": vaults or [],
|
||||
"active": True,
|
||||
"must_change_password": bool(must_change_password), # HUB-13
|
||||
"language": "fr", # default UI language
|
||||
"avatar": None, # profile picture data-URL (#113)
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
|
||||
@@ -17,6 +17,10 @@ Variables (voir ``.env.example``) :
|
||||
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
|
||||
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
|
||||
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
|
||||
- ``DRACO_SHAARIT_URL`` — base publique de ShaarIt (barre v0.2.0).
|
||||
- ``DRACO_HUB_URL`` — base publique du Hub (barre v0.3.0,
|
||||
HUB-1 : liens Télécharger / À Propos / Aides / Profil du menu
|
||||
compte ; le Hub n'a pas d'onglet dans la barre).
|
||||
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
|
||||
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
|
||||
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
|
||||
@@ -50,6 +54,8 @@ DEFAUTS: dict[str, str] = {
|
||||
"flowdeck": "https://flowdeck.dracodev.net",
|
||||
"tiktak": "https://tiktak.dracodev.net",
|
||||
"kanbante": "https://kanbante.dracodev.net",
|
||||
"shaarit": "https://shaarit.dracodev.net",
|
||||
"hub": "https://hub.dracodev.net",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
@@ -59,6 +65,8 @@ _VARIABLES: dict[str, str] = {
|
||||
"flowdeck": "DRACO_FLOWDECK_URL",
|
||||
"tiktak": "DRACO_TIKTAK_URL",
|
||||
"kanbante": "DRACO_KANBANTE_URL",
|
||||
"shaarit": "DRACO_SHAARIT_URL",
|
||||
"hub": "DRACO_HUB_URL",
|
||||
"gitea": "DRACO_GITEA_URL",
|
||||
}
|
||||
|
||||
@@ -71,6 +79,8 @@ _SLUGS_HOTE: dict[str, str] = {
|
||||
"flowdeck": "flowdeck",
|
||||
"tiktak": "tiktak",
|
||||
"kanbante": "kanbante",
|
||||
"shaarit": "shaarit",
|
||||
"hub": "hub",
|
||||
}
|
||||
|
||||
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
|
||||
|
||||
@@ -19,6 +19,27 @@
|
||||
# vide = cookie lié à l'hôte, utile en dev)
|
||||
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
|
||||
# liste de révocation (GET /api/sso/revocations)
|
||||
#
|
||||
# Registre des émissions (HUB-13, fusion #127) : chaque jti émis est
|
||||
# consigné par `sub` dans data/draco_issued.json (jti -> exp, purgé à
|
||||
# l'expiration) pour que l'administration des comptes puisse révoquer
|
||||
# toutes les sessions draco d'un utilisateur (désactivation,
|
||||
# suppression, réinitialisation du mot de passe) via revoke().
|
||||
# HUB-22 (#227) ajoute la lecture de ce registre
|
||||
# (issued_sessions_for_user) et la révocation unitaire contrôlée par
|
||||
# appartenance (revoke_one_for_user), exposées par l'API machine des
|
||||
# sessions (backend/routers/comptes_machine.py).
|
||||
#
|
||||
# Rôles du Hub (HUB-19, #228) : à l'ouverture de session draco,
|
||||
# `user_for_draco` consulte en plus le registre des rôles du Hub
|
||||
# (`backend/hub_roles.py`, contrat D3(a) posé par le pilote ShaarIt) :
|
||||
# niveau connu ``aucun`` → la session draco est refusée (l'authenti-
|
||||
# fication native d'ObsiGate, émettrice, n'est jamais touchée) ;
|
||||
# niveau connu → il est joint au dict utilisateur (``_hub_role``)
|
||||
# pour que les routes d'administration du canal draco exigent
|
||||
# ``administrateur`` ; rôle inconnu (Hub injoignable) → comportement
|
||||
# actuel, aucun verrouillage nouveau. Aucun rôle dans le jeton :
|
||||
# validation locale, émission et révocations sont inchangées.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -40,10 +61,12 @@ ALGORITHM = "HS256"
|
||||
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
|
||||
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
|
||||
REVOKED_FILE = Path("data/draco_revoked.json")
|
||||
ISSUED_FILE = Path("data/draco_issued.json")
|
||||
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
||||
_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp}
|
||||
|
||||
|
||||
def _secret() -> str:
|
||||
@@ -108,6 +131,115 @@ def revoked_list() -> list[dict]:
|
||||
for jti, exp in _load_revoked().items() if exp > now]
|
||||
|
||||
|
||||
# ── Registre des émissions (par utilisateur) ────────────────────────────
|
||||
|
||||
def _load_issued() -> dict[str, dict[str, int]]:
|
||||
global _issued
|
||||
if _issued is None:
|
||||
try:
|
||||
data = json.loads(ISSUED_FILE.read_text(encoding="utf-8"))
|
||||
_issued = {
|
||||
str(sub): {str(j): int(e) for j, e in tokens.items()}
|
||||
for sub, tokens in data.items()
|
||||
if isinstance(tokens, dict)
|
||||
} if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
_issued = {}
|
||||
return _issued
|
||||
|
||||
|
||||
def _save_issued() -> None:
|
||||
ISSUED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = ISSUED_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_issued or {}), encoding="utf-8")
|
||||
tmp.replace(ISSUED_FILE)
|
||||
|
||||
|
||||
def _register_issued(sub: str, jti: str, exp: int) -> None:
|
||||
"""Consigne un jti émis pour ``sub`` (purge des expirés au passage)."""
|
||||
if not sub:
|
||||
return
|
||||
try:
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
now = int(time.time())
|
||||
for key in list(store.keys()):
|
||||
store[key] = {j: e for j, e in store[key].items() if e > now}
|
||||
if not store[key]:
|
||||
del store[key]
|
||||
store.setdefault(sub, {})[jti] = int(exp)
|
||||
_save_issued()
|
||||
except Exception: # le registre ne casse jamais une émission
|
||||
logger.warning("Registre des émissions draco non écrit", exc_info=True)
|
||||
|
||||
|
||||
def revoke_all_for_user(sub: str) -> int:
|
||||
"""Révoque tous les jti draco actifs émis pour ``sub``.
|
||||
|
||||
Retourne le nombre de jti révoqués. Utilisé par l'administration
|
||||
des comptes (HUB-13) : désactivation, suppression, réinitialisation
|
||||
du mot de passe.
|
||||
"""
|
||||
if not sub:
|
||||
return 0
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
tokens = store.pop(str(sub), {})
|
||||
now = int(time.time())
|
||||
actifs = {j: e for j, e in tokens.items() if e > now}
|
||||
if tokens:
|
||||
_save_issued()
|
||||
for jti, exp in actifs.items():
|
||||
revoke(jti, exp)
|
||||
return len(actifs)
|
||||
|
||||
|
||||
def issued_sessions_for_user(sub: str) -> list[dict]:
|
||||
"""Sessions draco actives émises pour ``sub`` (HUB-22, #227).
|
||||
|
||||
Retourne ``[{jti, exp, revoquee}]`` triée par ``exp`` croissant,
|
||||
où ``revoquee`` indique si le ``jti`` figure déjà dans la liste des
|
||||
révocations. Les entrées expirées de cet utilisateur sont purgées
|
||||
du registre au passage (même patron que ``_register_issued``).
|
||||
"""
|
||||
if not sub:
|
||||
return []
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
now = int(time.time())
|
||||
tokens = store.get(str(sub), {})
|
||||
actifs = {j: e for j, e in tokens.items() if e > now}
|
||||
if len(actifs) != len(tokens):
|
||||
if actifs:
|
||||
store[str(sub)] = actifs
|
||||
else:
|
||||
store.pop(str(sub), None)
|
||||
_save_issued()
|
||||
revoques = _load_revoked()
|
||||
entrees = sorted(actifs.items(), key=lambda kv: kv[1])
|
||||
return [{"jti": jti, "exp": exp, "revoquee": jti in revoques}
|
||||
for jti, exp in entrees]
|
||||
|
||||
|
||||
def revoke_one_for_user(sub: str, jti: str) -> bool:
|
||||
"""Révoque un seul ``jti`` draco de ``sub`` (HUB-22, #227).
|
||||
|
||||
Ne révoque (via ``revoke``) que si le ``jti`` appartient bien à
|
||||
l'ensemble émis de ce ``sub`` et n'est pas expiré — jamais de
|
||||
révocation d'une session d'un autre utilisateur. Retourne ``True``
|
||||
si la révocation a eu lieu, ``False`` sinon.
|
||||
"""
|
||||
if not sub or not jti:
|
||||
return False
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
exp = store.get(str(sub), {}).get(jti)
|
||||
if exp is None or exp <= int(time.time()):
|
||||
return False
|
||||
revoke(jti, exp)
|
||||
return True
|
||||
|
||||
|
||||
# ── Émission / vérification ─────────────────────────────────────────────
|
||||
|
||||
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||
@@ -122,7 +254,9 @@ def issue_token(user: dict) -> tuple[str, str, int]:
|
||||
"exp": exp,
|
||||
"jti": jti,
|
||||
}
|
||||
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
|
||||
token = jwt.encode(claims, _secret(), algorithm=ALGORITHM)
|
||||
_register_issued(str(claims["sub"]), jti, exp)
|
||||
return token, jti, exp
|
||||
|
||||
|
||||
def decode_claims(token: str) -> dict | None:
|
||||
@@ -211,6 +345,28 @@ def user_for_draco(request) -> dict | None:
|
||||
user["_token_vaults"] = candidate.get("vaults", [])
|
||||
user["_token_scope"] = "full"
|
||||
user["_draco_sub"] = sub
|
||||
# HUB-19 (#228) : rôle du registre du Hub pour ce porteur.
|
||||
# Niveau connu « aucun » → pas de session établie par draco :
|
||||
# le refus est consigné sur la requête pour que `require_auth`
|
||||
# réponde un message explicite en français (et non un simple
|
||||
# 401 générique). Rôle connu → joint au dict (`_hub_role`) ;
|
||||
# rôle inconnu → `_hub_role` vaut None et le comportement
|
||||
# actuel s'applique, sans verrouillage nouveau.
|
||||
from backend import hub_roles
|
||||
|
||||
cookie = ""
|
||||
try:
|
||||
cookie = request.headers.get("cookie", "") or ""
|
||||
except Exception: # un objet requête minimal (tests) ne casse rien
|
||||
cookie = ""
|
||||
role = hub_roles.role_pour(sub, cookie)
|
||||
if role == "aucun":
|
||||
try:
|
||||
request.state.role_hub_refuse = "aucun"
|
||||
except Exception: # pas d'état mutable : refus quand même
|
||||
pass
|
||||
return None
|
||||
user["_hub_role"] = role
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# backend/hub_roles.py
|
||||
# Hub HUB-19 (propagation, #228) — Rôles par application : consommation
|
||||
# du registre du Hub par ObsiGate, sur le contrat posé par l'hôte
|
||||
# pilote ShaarIt (0.7.0, décision D3(a) du plan Hub v2).
|
||||
#
|
||||
# À l'ouverture de session draco (validation locale du cookie partagé,
|
||||
# `draco_session.user_for_draco`), ObsiGate demande au Hub le niveau
|
||||
# du porteur pour l'application `obsigate` :
|
||||
# ``GET {hub}/api/roles/obsigate``, en relayant l'en-tête Cookie
|
||||
# complet de la requête, ``Accept: application/json``, délai 2 s.
|
||||
# Réponse 200 : ``{"app", "sub", "role", "explicite",
|
||||
# "ttl_secondes": 60}``, rôle ∈ {aucun, utilisateur, administrateur}.
|
||||
#
|
||||
# Cache mémoire par `sub`, TTL 60 s, sur le patron du pilote (et du
|
||||
# cache de révocations draco) : l'horodatage est posé **avant**
|
||||
# l'appel — un échec conserve l'ancien cache et ne déclenche pas de
|
||||
# rafale de requêtes vers un Hub en panne.
|
||||
#
|
||||
# **Repli obligatoire** : Hub injoignable, en erreur ou réponse
|
||||
# illisible → rôle **inconnu** (``None``) et l'appelant garde son
|
||||
# comportement actuel. Le registre ne crée jamais un verrouillage
|
||||
# nouveau.
|
||||
#
|
||||
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
|
||||
# la barre commune (#223) : variable explicite `DRACO_HUB_URL` d'abord,
|
||||
# dérivation par l'hôte sœur (suffixe d'environnement de l'hôte de
|
||||
# l'agent) ensuite, défaut officiel enfin. Jamais d'URL dev figée.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger("obsigate.hub_roles")
|
||||
|
||||
#: Identifiant d'ObsiGate dans le registre des applications du Hub.
|
||||
APP = "obsigate"
|
||||
|
||||
#: Niveaux reconnus du registre (contrat D3(a)).
|
||||
ROLES = ("aucun", "utilisateur", "administrateur")
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
|
||||
CACHE_TTL_SECONDS = 60
|
||||
|
||||
#: Délai maximal de la consultation du Hub (contrat : 2 s).
|
||||
DELAI_SECONDS = 2
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, str] = {} # sub -> rôle connu
|
||||
_cache_at: float | None = None # horodatage (monotone) du dernier appel
|
||||
|
||||
|
||||
def _hub_url() -> str:
|
||||
"""Base publique du Hub, résolue comme pour la barre commune."""
|
||||
from backend.draco_config import app_url
|
||||
|
||||
return app_url("hub")
|
||||
|
||||
|
||||
def _consulter_hub(url: str, cookie: str) -> str | None:
|
||||
"""Appelle le Hub et retourne le rôle validé, ou ``None``.
|
||||
|
||||
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON illisible,
|
||||
rôle hors contrat) retourne ``None`` : rôle inconnu, repli.
|
||||
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
|
||||
quel — c'est lui qui prouve l'identité du porteur au Hub.
|
||||
"""
|
||||
requete = urllib.request.Request(
|
||||
url,
|
||||
method="GET",
|
||||
headers={"Cookie": cookie, "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
|
||||
if reponse.status != 200:
|
||||
return None
|
||||
donnees = json.loads(reponse.read().decode("utf-8"))
|
||||
except Exception: # le registre ne casse jamais une ouverture de session
|
||||
return None
|
||||
if not isinstance(donnees, dict):
|
||||
return None
|
||||
role = donnees.get("role")
|
||||
return role if role in ROLES else None
|
||||
|
||||
|
||||
def role_pour(sub: str, cookie: str) -> str | None:
|
||||
"""Niveau connu pour ce `sub` : un des ``ROLES`` si le Hub a
|
||||
répondu, ``None`` si le rôle est inconnu (Hub injoignable/erreur)
|
||||
— repli = comportement actuel de l'appelant.
|
||||
|
||||
`cookie` est l'en-tête Cookie complet de la requête d'origine,
|
||||
relayé tel quel au Hub.
|
||||
"""
|
||||
global _cache_at
|
||||
if not sub:
|
||||
return None
|
||||
with _lock:
|
||||
if _cache_at is not None \
|
||||
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS \
|
||||
and sub in _cache:
|
||||
return _cache[sub]
|
||||
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
|
||||
# cache reste en vigueur, un échec ne déclenche pas de rafale.
|
||||
_cache_at = time.monotonic()
|
||||
url = f"{_hub_url().rstrip('/')}/api/roles/{APP}"
|
||||
role = _consulter_hub(url, cookie)
|
||||
if role is None:
|
||||
logger.warning(
|
||||
"Registre des rôles du Hub indisponible (%s) — "
|
||||
"comportement actuel conservé", url)
|
||||
return None
|
||||
with _lock:
|
||||
_cache[sub] = role
|
||||
_cache_at = time.monotonic()
|
||||
return role
|
||||
|
||||
|
||||
def reinitialiser_cache() -> None:
|
||||
"""Vide le cache des rôles (tests, redémarrage à chaud)."""
|
||||
global _cache_at
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
_cache_at = None
|
||||
@@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
Also reads optional configuration:
|
||||
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
|
||||
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
|
||||
- DIR_N_READONLY: "true"/"false" — vault DIR déclaré en lecture seule
|
||||
(les mutations sont refusées en 403, l'UI masque les actions d'écriture)
|
||||
- DIR_N_IGNORED_DIRS: noms de dossiers ignorés pour ce vault, séparés
|
||||
par des virgules (toute profondeur, en plus du global IGNORED_DIRS)
|
||||
- DIR_N_IGNORED_PATHS: motifs fnmatch sur le chemin relatif POSIX dans
|
||||
le vault, séparés par des virgules (un dossier qui matche cache tout
|
||||
son sous-arbre) — navigation, lecture et indexation les respectent
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to configuration dicts with keys:
|
||||
@@ -104,6 +111,9 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
- attachmentsPath: relative attachments folder (optional)
|
||||
- scanAttachmentsOnStartup: boolean (default True)
|
||||
- type: "VAULT" or "DIR"
|
||||
- readonly: boolean (DIR only, default False)
|
||||
- ignoredDirs: set of directory names (DIR only, default empty)
|
||||
- ignoredPaths: list of fnmatch patterns (DIR only, default empty)
|
||||
"""
|
||||
vaults: dict[str, dict[str, Any]] = {}
|
||||
n = 1
|
||||
@@ -131,12 +141,23 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
path = os.environ.get(f"DIR_{n}_PATH")
|
||||
if not name or not path:
|
||||
break
|
||||
|
||||
|
||||
readonly = os.environ.get(f"DIR_{n}_READONLY", "false").lower() == "true"
|
||||
ignored_dirs = {
|
||||
d.strip() for d in os.environ.get(f"DIR_{n}_IGNORED_DIRS", "").split(",") if d.strip()
|
||||
}
|
||||
ignored_paths = [
|
||||
p.strip() for p in os.environ.get(f"DIR_{n}_IGNORED_PATHS", "").split(",") if p.strip()
|
||||
]
|
||||
|
||||
vaults[name] = {
|
||||
"path": path,
|
||||
"attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": False,
|
||||
"type": "DIR"
|
||||
"type": "DIR",
|
||||
"readonly": readonly,
|
||||
"ignoredDirs": ignored_dirs,
|
||||
"ignoredPaths": ignored_paths,
|
||||
}
|
||||
n += 1
|
||||
|
||||
@@ -509,7 +530,8 @@ def _scan_vault(
|
||||
Args:
|
||||
vault_name: Display name of the vault.
|
||||
vault_path: Absolute filesystem path to the vault root.
|
||||
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
|
||||
vault_cfg: Optional vault configuration dict — ses exclusions
|
||||
(``ignoredDirs`` / ``ignoredPaths``) sont appliquées au scan.
|
||||
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
|
||||
previous scan used for differential reuse.
|
||||
|
||||
@@ -529,6 +551,16 @@ def _scan_vault(
|
||||
|
||||
root_resolved = vault_root.resolve(strict=False)
|
||||
|
||||
# Exclusions propres au vault (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) :
|
||||
# elles s'ajoutent au global IGNORED_DIRS, sans le modifier. Le cfg est
|
||||
# celui passé par l'appelant — l'index global ne contient pas encore ce
|
||||
# vault pendant son premier scan, chemin_exclu() (qui lit l'index) ne
|
||||
# peut donc pas servir ici.
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
cfg = vault_cfg or {}
|
||||
ignored_names = IGNORED_DIRS | set(cfg.get("ignoredDirs") or [])
|
||||
|
||||
# BUG-032: walk without following symlinks and refuse any symlink that
|
||||
# escapes the vault root, so external data can never be indexed/exposed.
|
||||
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
|
||||
@@ -537,7 +569,11 @@ def _scan_vault(
|
||||
# Prune ignored and symlinked directories in place (no recursion).
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in IGNORED_DIRS and not (current_dir / d).is_symlink()
|
||||
if d not in ignored_names
|
||||
and not (current_dir / d).is_symlink()
|
||||
and not chemin_exclu_dans_config(
|
||||
cfg, str((current_dir / d).relative_to(vault_root)).replace("\\", "/")
|
||||
)
|
||||
]
|
||||
|
||||
for d in dirnames:
|
||||
@@ -565,6 +601,10 @@ def _scan_vault(
|
||||
|
||||
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
# Exclusion propre au vault (motif de chemin, ex. un .env ciblé).
|
||||
if chemin_exclu_dans_config(cfg, rel_path_str):
|
||||
continue
|
||||
|
||||
ext = fpath.suffix.lower()
|
||||
# Also match extensionless files named like Dockerfile, Makefile
|
||||
basename_lower = fpath.name.lower()
|
||||
|
||||
@@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
# #223 : l'origine du service d'agents n'est pas figée — elle
|
||||
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
|
||||
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
|
||||
from backend.draco_config import agent_origine
|
||||
from backend.draco_config import agent_origine, origine, urls_publiques
|
||||
|
||||
agent = agent_origine()
|
||||
# Fusion #69 : le panneau Alertes unifiées lit les API
|
||||
# notifications des applications soeurs depuis cet hôte ;
|
||||
# leurs origines rejoignent connect-src (même résolution
|
||||
# draco_config que les liens de la barre : explicite >
|
||||
# dérivation depuis l'hôte de l'agent > défaut officiel).
|
||||
_soeurs69 = " ".join(sorted({
|
||||
origine(v)
|
||||
for k, v in urls_publiques().items()
|
||||
if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
} - {""}))
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
|
||||
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
f"frame-src 'self' blob: {agent}; "
|
||||
@@ -521,6 +531,15 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
|
||||
# après le CORSMiddleware global donc exécutée avant lui (dernier
|
||||
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
|
||||
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
|
||||
# La politique same-origin explicite du middleware global reste la
|
||||
# règle par défaut pour tout le reste.
|
||||
from backend.recherche_cors import RechercheCorsMiddleware
|
||||
|
||||
app.add_middleware(RechercheCorsMiddleware)
|
||||
# Auth router
|
||||
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
||||
from backend.ai_routes import router as ai_router
|
||||
@@ -531,6 +550,7 @@ from backend.auth.router import router as auth_router
|
||||
from backend.bookslm_routes import router as bookslm_router
|
||||
from backend.routers.backups import router as backups_router
|
||||
from backend.routers.coffres import router as coffres_router
|
||||
from backend.routers.comptes_machine import router as comptes_machine_router
|
||||
from backend.routers.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
@@ -566,6 +586,7 @@ app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
|
||||
app.include_router(comptes_machine_router) # HUB-13 (fusion #127) — Admin des comptes, machine-à-machine
|
||||
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||
@@ -940,6 +961,78 @@ if FRONTEND_DIR.exists():
|
||||
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Admin page not found")
|
||||
|
||||
@app.get("/recherche", response_class=HTMLResponse)
|
||||
async def serve_recherche_page(request: Request):
|
||||
"""Page « Rechercher partout » (fusion #68) — déclarée AVANT le
|
||||
catch-all SPA comme /admin.html, sinon index.html serait servi.
|
||||
|
||||
frontend/recherche.html est servi avec les jetons {{URL_*}}
|
||||
remplacés par urls_publiques() (même contrat que le fragment
|
||||
de barre, jetons {{URL_SHAARIT}} et {{URL_AGENT}} inclus pour
|
||||
les onglets ShaarIt (barre v0.2.0) et Console
|
||||
Spark de la barre re-vendangée) et le nonce CSP frais ; la
|
||||
réponse pose sa propre CSP (préservée par
|
||||
SecurityHeadersMiddleware, qui ne la pose que si elle est
|
||||
absente) dont le connect-src autorise les origines sœurs ET
|
||||
l'origine de l'agent — sinon le navigateur bloquerait les
|
||||
appels sœurs et l'appel console (onglet Console Spark,
|
||||
GET /api/v1/recherche) avant même CORS. La CSP par défaut
|
||||
(same-origin) reste inchangée pour toutes les autres pages.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.csp import inject_csp_nonce
|
||||
from backend.draco_config import urls_publiques
|
||||
from backend.recherche_cors import origin_of
|
||||
|
||||
recherche_file = FRONTEND_DIR / "recherche.html"
|
||||
if not recherche_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Recherche page not found")
|
||||
html = recherche_file.read_text(encoding="utf-8")
|
||||
urls = urls_publiques()
|
||||
for jeton, cle in (
|
||||
("{{URL_OBSIGATE}}", "obsigate"),
|
||||
("{{URL_FLOWDECK}}", "flowdeck"),
|
||||
("{{URL_TIKTAK}}", "tiktak"),
|
||||
("{{URL_KANBANTE}}", "kanbante"),
|
||||
("{{URL_SHAARIT}}", "shaarit"),
|
||||
("{{URL_AGENT}}", "agent"),
|
||||
):
|
||||
html = html.replace(jeton, urls.get(cle, ""))
|
||||
script = (
|
||||
"<script>window.DRACO_URLS = "
|
||||
+ _json.dumps(urls).replace("<", "\\u003c")
|
||||
+ ";</script>"
|
||||
)
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else:
|
||||
html = script + html
|
||||
html = inject_csp_nonce(html, request.state.csp_nonce)
|
||||
soeurs = " ".join(sorted({
|
||||
origin_of(urls.get(cle, "")) or ""
|
||||
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
|
||||
"shaarit", "agent")
|
||||
} - {""}))
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{request.state.csp_nonce}'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
f"connect-src 'self' {soeurs}; "
|
||||
"font-src 'self' data:; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
return HTMLResponse(
|
||||
content=html,
|
||||
headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp},
|
||||
)
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
"""Serve the SPA index.html for all non-API routes."""
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
|
||||
|
||||
La page « Rechercher partout » (``/recherche``) appelle, depuis le
|
||||
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
|
||||
en-têtes CORS, le navigateur refuse la lecture des réponses
|
||||
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
|
||||
Ce module n'autorise donc que :
|
||||
|
||||
- le chemin ``/api/search`` (jamais les autres routes, jamais les
|
||||
écritures) ;
|
||||
- les origines sœurs : les origines officielles **prod ET dev,
|
||||
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
|
||||
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
|
||||
ET dev — fédération de la recherche depuis la console,
|
||||
2026-10-28), **plus**
|
||||
celles de ``backend.draco_config`` (``urls_publiques()`` :
|
||||
explicite > dérivation par l'hôte de l'agent > défaut officiel)
|
||||
— jamais ``*``. Correctif 2026-10-28 (fusion #68/#69, matrice
|
||||
constatée par Bruno) : l'ancienne allowlist ne contenait que
|
||||
l'environnement de la configuration (plus celui de l'hôte de la
|
||||
requête), jamais les deux à la fois — une page de l'autre
|
||||
environnement voyait sa section afficher « Injoignable depuis
|
||||
cet hôte (CORS ou réseau) » ;
|
||||
- avec credentials (le cookie SSO ``draco_session``, accepté par
|
||||
``/api/search`` via le repli de ``backend.auth.middleware``, ou
|
||||
le cookie natif ``access_token``).
|
||||
|
||||
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
|
||||
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
routes ni à la politique same-origin explicite du CORSMiddleware
|
||||
global (``allow_origins=[]``), qui reste la règle par défaut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SEARCH_PATH = "/api/search"
|
||||
|
||||
PROD_ORIGINS = frozenset({
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://shaarit.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://shaarit-dev.dracodev.net",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
})
|
||||
|
||||
|
||||
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
"""Même règle que la barre v0.1.6 (voir frontend/js/recherche.js)."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 4 and first.endswith("-dev")
|
||||
|
||||
|
||||
def is_prod_host(hostname: str | None) -> bool:
|
||||
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 0 and not first.endswith("-dev")
|
||||
|
||||
|
||||
def origin_of(url: str | None) -> str | None:
|
||||
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return None
|
||||
host = parsed.hostname.lower()
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return f"{parsed.scheme}://{host}{port}"
|
||||
|
||||
|
||||
def sister_origins(resolved: dict | None = None, request_host: str | None = None) -> frozenset:
|
||||
"""Origines autorisées pour la lecture cross-origin de /api/search.
|
||||
|
||||
``resolved`` : dictionnaire rendu par ``urls_publiques()``.
|
||||
Sans argument, le résolveur est appelé (import paresseux) ; en cas
|
||||
d'échec, repli sur prod + dev officiels (premier parti uniquement).
|
||||
``request_host`` ajoute l'ensemble officiel de l'environnement de
|
||||
la requête (voir le docstring du module).
|
||||
"""
|
||||
origins = set()
|
||||
if resolved is None:
|
||||
try:
|
||||
from backend.draco_config import urls_publiques
|
||||
|
||||
resolved = urls_publiques()
|
||||
except Exception:
|
||||
resolved = {}
|
||||
for key in _SOEUR_KEYS:
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
|
||||
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
|
||||
# l'environnement de la requête, désormais inclus d'office).
|
||||
# ``resolved`` n'ajoute que des origines de configuration sans
|
||||
# jamais retirer une origine officielle.
|
||||
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
|
||||
if not origin:
|
||||
return None
|
||||
if origin.strip() not in sister_origins(resolved, request_host):
|
||||
return None
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin.strip(),
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
|
||||
|
||||
def preflight_headers(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
|
||||
headers = cors_headers_for(origin, resolved, request_host)
|
||||
if headers is None:
|
||||
return None
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
class RechercheCorsMiddleware:
|
||||
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
headers_in = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
origin = headers_in.get("origin", "")
|
||||
request_host = (
|
||||
headers_in.get("x-forwarded-host", "") or headers_in.get("host", "")
|
||||
).split(",")[0].strip()
|
||||
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
|
||||
preflight = preflight_headers(origin, request_host=request_host)
|
||||
if preflight is not None:
|
||||
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in preflight.items()]
|
||||
headers_out.append((b"content-length", b"0"))
|
||||
await send({"type": "http.response.start", "status": 204,
|
||||
"headers": headers_out})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return
|
||||
if path != SEARCH_PATH:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_cors(message):
|
||||
if message["type"] == "http.response.start":
|
||||
cors = cors_headers_for(origin, request_host=request_host)
|
||||
if cors is not None:
|
||||
message["headers"] = list(message.get("headers", [])) + [
|
||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in cors.items()
|
||||
]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cors)
|
||||
@@ -22,10 +22,12 @@ httpx>=0.27.0
|
||||
# l'utilisateur). #224 : la version préinstallée dans la toolcache du
|
||||
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
|
||||
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
|
||||
# corrigées au plus tard en 6.21.2 — le plancher doit être >= 6.21.2 :
|
||||
# corrigées au plus tard en 6.21.2. La 6.21.2 est à son tour frappée
|
||||
# (CVE-2026-119203 et suivantes, relevées par le job security sur main),
|
||||
# corrigées en 6.21.4 — le plancher doit être >= 6.21.4 :
|
||||
# en dessous, pip répond « already satisfied » pour la toolcache et
|
||||
# n'aligne jamais (même piège que BUG-093).
|
||||
pypdf>=6.21.2
|
||||
pypdf>=6.21.4
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
@@ -79,3 +81,9 @@ cryptography>=44.0
|
||||
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
|
||||
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
|
||||
msgpack>=1.2.3
|
||||
# Plancher de sécurité : pyasn1 est transitive (python-jose/rsa), version
|
||||
# 0.6.4 relevée par le job security (pip-audit, bloquant) —
|
||||
# CVE-2026-119194 à CVE-2026-119197, corrigées en 0.6.5. Sans plancher
|
||||
# explicite, pip répond « already satisfied » pour la toolcache et
|
||||
# n'aligne jamais (même piège que BUG-093).
|
||||
pyasn1>=0.6.5
|
||||
|
||||
@@ -12,6 +12,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
||||
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
|
||||
un consommateur authentifié par jeton (hash + jti, jamais la session
|
||||
utilisateur), avec un octroi actif et un contexte obligatoire.
|
||||
- Seule exception, voulue : ``POST /api/coffres/{slug}/secrets/{nom}/reveler``
|
||||
rend la valeur au gestionnaire du coffre authentifié par sa session
|
||||
(propriétaire, ou admin sur coffre d'instance — jamais un simple
|
||||
lecteur par octroi utilisateur), pour l'affichage et la copie manuelle
|
||||
dans le panneau détail. Chaque révélation est auditée
|
||||
(``revelation_manuelle``) et met à jour la dernière utilisation ; la
|
||||
valeur n'est jamais journalisée et le frontend l'efface dès qu'elle
|
||||
est masquée ou que le détail change.
|
||||
- Autorisations (spec §6) : le propriétaire seul sur son coffre
|
||||
personnel ; l'admin gère les coffres d'instance, les consommateurs et
|
||||
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
|
||||
@@ -64,6 +72,7 @@ from backend.coffre_store import (
|
||||
create_secret,
|
||||
delete_secret,
|
||||
distribuer_valeur,
|
||||
est_expire,
|
||||
get_coffre,
|
||||
get_coffre_by_id,
|
||||
get_coffre_or_404,
|
||||
@@ -495,6 +504,50 @@ async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/coffres/distribuables")
|
||||
async def api_distribuables(request: Request):
|
||||
"""Secrets distribuables pour un consommateur (issue #43).
|
||||
|
||||
Authentification par jeton consommateur (la même que
|
||||
/distribuer), jamais par session utilisateur : c'est le
|
||||
serveur agent qui appelle, pour alimenter la liste déroulante
|
||||
de ses fournisseurs LLM. Retour = métadonnées seules des
|
||||
secrets pour lesquels ce consommateur a un octroi ACTIF
|
||||
(coffre actif, secret non expiré, octroi ni révoqué ni
|
||||
expiré, y compris les octrois coffre-wide). Un consommateur
|
||||
sans octroi reçoit une liste vide — jamais d'erreur
|
||||
révélatrice, jamais de valeur.
|
||||
"""
|
||||
_exiger_actives()
|
||||
auth = request.headers.get("Authorization", "")
|
||||
jeton = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
consommateur = verifier_jeton_consommateur(jeton)
|
||||
if consommateur is None:
|
||||
raise HTTPException(status_code=401,
|
||||
detail="consommateur_invalide")
|
||||
if consommateur["statut"] != "actif":
|
||||
raise HTTPException(status_code=403,
|
||||
detail="consommateur_revoque")
|
||||
sortie = []
|
||||
for coffre in list_coffres():
|
||||
if coffre["statut"] != "actif":
|
||||
continue
|
||||
for secret in list_secrets(coffre["id"]):
|
||||
if est_expire(secret.get("expiration")):
|
||||
continue
|
||||
statut, _octroi = statut_octroi_consommateur(
|
||||
consommateur["id"], coffre["id"], secret["id"])
|
||||
if statut != "ok":
|
||||
continue
|
||||
sortie.append({
|
||||
"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
"coffre": coffre["slug"],
|
||||
"nom": secret["nom"],
|
||||
"version": secret["version_courante"],
|
||||
})
|
||||
return sortie
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Un coffre, ses secrets, ses octrois, son journal
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -577,6 +630,50 @@ async def api_rotate_secret(
|
||||
return _metadata_secret(coffre, secret)
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/secrets/{nom}/reveler")
|
||||
async def api_reveler_secret(
|
||||
slug: str, nom: str, current_user=Depends(require_auth)
|
||||
):
|
||||
"""Révélation manuelle pour le panneau détail (afficher / copier).
|
||||
|
||||
Session utilisateur + gestion du coffre exigées : un lecteur par
|
||||
octroi utilisateur voit les métadonnées mais ne révèle jamais la
|
||||
valeur. Chaque appel est audité et touche la dernière utilisation.
|
||||
"""
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
secret = _secret_or_404(coffre, nom)
|
||||
try:
|
||||
valeur = distribuer_valeur(coffre, secret)
|
||||
except SecretExpireError:
|
||||
_audit(_username(current_user), "revelation_refusee",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="secret_expire")
|
||||
raise HTTPException(status_code=410, detail="secret_expire") from None
|
||||
except KEKAbsenteError:
|
||||
_audit(_username(current_user), "revelation_refusee",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="kek_absente")
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
_audit(_username(current_user), "revelation_refusee",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="crypto_indisponible")
|
||||
raise HTTPException(status_code=503,
|
||||
detail="crypto_indisponible") from None
|
||||
toucher_utilisation(secret["id"])
|
||||
_audit(_username(current_user), "revelation_manuelle",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok")
|
||||
return {"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
"valeur": valeur, "version": secret["version_courante"]}
|
||||
|
||||
|
||||
@router.delete("/api/coffres/{slug}/secrets/{nom}")
|
||||
async def api_delete_secret(
|
||||
slug: str, nom: str, current_user=Depends(require_auth)
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
# backend/routers/comptes_machine.py
|
||||
# HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine.
|
||||
#
|
||||
# Le Hub (interface d'administration des utilisateurs) administre les
|
||||
# comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan
|
||||
# Hub v2) via ces routes, en lecture et en écriture : lister, lire le
|
||||
# profil étendu, créer, modifier, désactiver/réactiver, supprimer,
|
||||
# réinitialiser le mot de passe et le MFA.
|
||||
#
|
||||
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
|
||||
# avec la même journalisation, les routes « sessions » : lister les
|
||||
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
|
||||
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
|
||||
# désactiver le compte), via le registre des émissions de HUB-13.
|
||||
#
|
||||
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
|
||||
# d'administration existant (``/api/admin/…``), avec un segment
|
||||
# ``machine`` explicite qui le distingue des routes d'administration par
|
||||
# session (``/api/auth/admin/users``, garde ``require_admin``). Aucune
|
||||
# session utilisateur — même administratrice — n'ouvre ces routes :
|
||||
# seule la clé machine dédiée les protège.
|
||||
#
|
||||
# Authentification machine (même famille que ``GET /api/sso/revocations``) :
|
||||
# DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête
|
||||
# ``X-Draco-Machine-Key``). Distincte de
|
||||
# ``DRACO_SSO_MACHINE_KEY`` : le périmètre
|
||||
# (écriture de comptes) est plus sensible que
|
||||
# la seule lecture des révocations (plan §3).
|
||||
# Sans elle, l'API est inerte (404, comme la
|
||||
# liste de révocations non configurée).
|
||||
# X-Draco-Actor identité de service appelante, facultative
|
||||
# (défaut ``service-machine``) — consignée
|
||||
# comme acteur dans le journal d'audit.
|
||||
#
|
||||
# Chaque action est journalisée côté ObsiGate dans le journal d'audit
|
||||
# existant (``data/audit.log``, voir backend.audit.log_compte_admin).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from backend import draco_session
|
||||
from backend.audit import log_compte_admin
|
||||
from backend.auth.password import validate_password_strength
|
||||
from backend.auth.user_store import (
|
||||
create_user,
|
||||
delete_user,
|
||||
get_all_users,
|
||||
get_user,
|
||||
update_user,
|
||||
)
|
||||
from backend.services.net import get_client_ip
|
||||
|
||||
logger = logging.getLogger("obsigate.comptes_machine")
|
||||
|
||||
router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"])
|
||||
|
||||
#: Variable d'environnement portant la clé machine dédiée à cette API.
|
||||
MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY"
|
||||
#: En-tête de la clé machine (même nom que pour /api/sso/revocations).
|
||||
MACHINE_KEY_HEADER = "X-Draco-Machine-Key"
|
||||
#: En-tête facultatif : identité de service appelante (journal d'audit).
|
||||
ACTOR_HEADER = "X-Draco-Actor"
|
||||
#: Acteur consigné quand l'appelant ne se nomme pas.
|
||||
DEFAULT_ACTOR = "service-machine"
|
||||
|
||||
|
||||
# ── Garde machine ───────────────────────────────────────────────────────
|
||||
|
||||
def require_machine_admin(request: Request) -> str:
|
||||
"""Exige la clé machine dédiée ; retourne l'identité de service.
|
||||
|
||||
Jamais de repli sur une session utilisateur : sans en-tête valide,
|
||||
la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de
|
||||
``GET /api/sso/revocations``).
|
||||
"""
|
||||
machine_key = os.environ.get(MACHINE_KEY_ENV, "")
|
||||
if not machine_key:
|
||||
raise HTTPException(404, "Non configuré")
|
||||
provided = request.headers.get(MACHINE_KEY_HEADER, "")
|
||||
if not provided or not hmac.compare_digest(provided, machine_key):
|
||||
raise HTTPException(401, "Clé machine invalide")
|
||||
actor = (request.headers.get(ACTOR_HEADER, "") or "").strip()
|
||||
return actor[:64] if actor else DEFAULT_ACTOR
|
||||
|
||||
|
||||
# ── Profil étendu (liste blanche — jamais de secret) ────────────────────
|
||||
|
||||
def _profil(user: dict) -> dict:
|
||||
"""Profil étendu d'un compte, sûr à exposer à un service.
|
||||
|
||||
Liste blanche stricte : ni ``password_hash``, ni secret TOTP
|
||||
(``mfa_secret``/``mfa_secret_pending``), ni codes de récupération,
|
||||
ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non)
|
||||
est exposé. L'avatar suit le modèle existant : la valeur stockée
|
||||
(data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen
|
||||
de présence ``avatar_present`` pour les listes légères.
|
||||
"""
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"username": user.get("username"),
|
||||
"display_name": user.get("display_name"),
|
||||
"language": user.get("language", "fr"),
|
||||
"avatar": user.get("avatar"),
|
||||
"avatar_present": bool(user.get("avatar")),
|
||||
"role": user.get("role"),
|
||||
"active": bool(user.get("active")),
|
||||
"vaults": user.get("vaults", []),
|
||||
"mfa_enabled": bool(user.get("mfa_enabled")),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
"must_change_password": bool(user.get("must_change_password")),
|
||||
"created_at": user.get("created_at"),
|
||||
"last_login": user.get("last_login"),
|
||||
}
|
||||
|
||||
|
||||
def _compte_ou_404(username: str) -> dict:
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise HTTPException(404, "Compte introuvable")
|
||||
return user
|
||||
|
||||
|
||||
def _identifiant_sessions(user: dict) -> str:
|
||||
"""Clé du registre des émissions draco pour un compte.
|
||||
|
||||
C'est le ``sub`` des jetons draco : l'identifiant du compte
|
||||
(``id``), à défaut son nom d'utilisateur — exactement la clé
|
||||
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
|
||||
"""
|
||||
return str(user.get("id") or user.get("username") or "")
|
||||
|
||||
|
||||
def _revoquer_sessions(user: dict) -> int:
|
||||
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco.
|
||||
|
||||
Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via
|
||||
le mécanisme existant (``draco_session.revoke``, exposé aux autres
|
||||
applications par ``GET /api/sso/revocations``). Les jetons natifs
|
||||
sont invalidés par le garde ``password_changed_at`` (BUG-028),
|
||||
avancé par l'appelant (désactivation, réinitialisation du mot de
|
||||
passe) ou par le drapeau ``active`` tant que le compte est inactif.
|
||||
"""
|
||||
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
|
||||
|
||||
|
||||
def _audit(request: Request, acteur: str, action: str, cible: str,
|
||||
details: dict | None = None) -> None:
|
||||
log_compte_admin(acteur, action, cible,
|
||||
ip=get_client_ip(request), details=details)
|
||||
|
||||
|
||||
# ── Modèles de requête ──────────────────────────────────────────────────
|
||||
|
||||
class CreerCompteRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: str | None = None
|
||||
role: str = "user"
|
||||
vaults: list[str] = []
|
||||
|
||||
@validator("password")
|
||||
def password_valid(cls, v):
|
||||
return validate_password_strength(v)
|
||||
|
||||
@validator("username")
|
||||
def username_valid(cls, v):
|
||||
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
|
||||
raise ValueError("2-32 caractères alphanumériques, _ ou -")
|
||||
return v.lower()
|
||||
|
||||
@validator("role")
|
||||
def role_valid(cls, v):
|
||||
if v not in ("admin", "user"):
|
||||
raise ValueError("Rôle invalide")
|
||||
return v
|
||||
|
||||
|
||||
class ModifierCompteRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
language: str | None = None
|
||||
vaults: list[str] | None = None
|
||||
|
||||
@validator("role")
|
||||
def role_valid(cls, v):
|
||||
if v is not None and v not in ("admin", "user"):
|
||||
raise ValueError("Rôle invalide")
|
||||
return v
|
||||
|
||||
@validator("language")
|
||||
def language_valid(cls, v):
|
||||
if v is not None and v not in ("fr", "en"):
|
||||
raise ValueError("Langue invalide (fr | en)")
|
||||
return v
|
||||
|
||||
|
||||
class ReinitialiserMotDePasseRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
@validator("password")
|
||||
def password_valid(cls, v):
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
# ── Lecture ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def lister_comptes(acteur: str = Depends(require_machine_admin)):
|
||||
"""Liste tous les comptes (profils étendus, sans aucun secret)."""
|
||||
comptes = [_profil(u) for u in get_all_users()]
|
||||
return {"comptes": comptes, "total": len(comptes)}
|
||||
|
||||
|
||||
@router.get("/{username}")
|
||||
async def lire_compte(username: str,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Lit un compte et son profil étendu."""
|
||||
return _profil(_compte_ou_404(username))
|
||||
|
||||
|
||||
# ── Écriture ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def creer_compte(req: CreerCompteRequest, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Crée un compte. Le mot de passe initial est temporaire :
|
||||
``must_change_password`` est posé (changement forcé signalé à la
|
||||
première connexion, comme après une réinitialisation)."""
|
||||
if get_user(req.username) is not None:
|
||||
raise HTTPException(409, "Identifiant déjà utilisé")
|
||||
try:
|
||||
user = create_user(
|
||||
req.username, req.password, req.role, req.vaults,
|
||||
req.display_name, must_change_password=True,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e)) from e
|
||||
# #194 : dossier personnel — même provisionnement que la création
|
||||
# par session administratrice (best effort).
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
await ensure_user_home(req.username)
|
||||
fresh = get_user(req.username)
|
||||
if fresh:
|
||||
user = fresh
|
||||
_audit(request, acteur, "compte_cree", req.username,
|
||||
{"role": req.role})
|
||||
return _profil(user)
|
||||
|
||||
|
||||
@router.patch("/{username}")
|
||||
async def modifier_compte(username: str, req: ModifierCompteRequest,
|
||||
request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Modifie un compte (nom affiché, rôle global, langue, vaults)."""
|
||||
_compte_ou_404(username)
|
||||
updates = req.dict(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(400, "Aucun champ à modifier")
|
||||
updated = update_user(username, updates)
|
||||
_audit(request, acteur, "compte_modifie", username,
|
||||
{"champs": sorted(updates.keys())})
|
||||
return _profil(updated)
|
||||
|
||||
|
||||
@router.post("/{username}/desactiver")
|
||||
async def desactiver_compte(username: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Désactive un compte : plus aucun nouveau jeton n'est émis
|
||||
(connexion refusée) et les ``jti`` draco actifs sont révoqués."""
|
||||
user = _compte_ou_404(username)
|
||||
updated = update_user(username, {
|
||||
"active": False,
|
||||
# BUG-028 : avancer le repère invalide aussi les jetons natifs
|
||||
# déjà émis — ils ne ressuscitent pas à la réactivation.
|
||||
"password_changed_at": time.time(),
|
||||
})
|
||||
nb_jti = _revoquer_sessions(user)
|
||||
_audit(request, acteur, "compte_desactive", username,
|
||||
{"jti_revoques": nb_jti})
|
||||
return _profil(updated)
|
||||
|
||||
|
||||
@router.post("/{username}/reactiver")
|
||||
async def reactiver_compte(username: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Réactive un compte désactivé (les anciennes sessions restent
|
||||
révoquées : une nouvelle connexion est nécessaire)."""
|
||||
_compte_ou_404(username)
|
||||
updated = update_user(username, {"active": True})
|
||||
_audit(request, acteur, "compte_reactive", username)
|
||||
return _profil(updated)
|
||||
|
||||
|
||||
@router.delete("/{username}")
|
||||
async def supprimer_compte(username: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Supprime un compte et révoque ses ``jti`` draco actifs."""
|
||||
user = _compte_ou_404(username)
|
||||
nb_jti = _revoquer_sessions(user)
|
||||
try:
|
||||
delete_user(username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
# #194 : fermer le vault du dossier personnel (dossier conservé),
|
||||
# comme la suppression par session administratrice.
|
||||
from backend.user_home import release_user_home
|
||||
|
||||
await release_user_home(username)
|
||||
_audit(request, acteur, "compte_supprime", username,
|
||||
{"jti_revoques": nb_jti})
|
||||
return {"message": f"Compte '{username}' supprimé"}
|
||||
|
||||
|
||||
@router.post("/{username}/reinitialiser-mot-de-passe")
|
||||
async def reinitialiser_mot_de_passe(
|
||||
username: str, req: ReinitialiserMotDePasseRequest,
|
||||
request: Request, acteur: str = Depends(require_machine_admin)):
|
||||
"""Pose un nouveau mot de passe temporaire : changement forcé à la
|
||||
première connexion (``must_change_password``, signalé par la
|
||||
connexion et ``GET /api/auth/me``, effacé au changement effectif).
|
||||
Les sessions existantes sont révoquées (repère BUG-028 + ``jti``
|
||||
draco). Le mot de passe n'est jamais journalisé."""
|
||||
user = _compte_ou_404(username)
|
||||
updated = update_user(username, {
|
||||
"password": req.password,
|
||||
"must_change_password": True,
|
||||
})
|
||||
nb_jti = _revoquer_sessions(user)
|
||||
_audit(request, acteur, "mot_de_passe_reinitialise", username,
|
||||
{"jti_revoques": nb_jti})
|
||||
return _profil(updated)
|
||||
|
||||
|
||||
@router.post("/{username}/reinitialiser-mfa")
|
||||
async def reinitialiser_mfa(username: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé
|
||||
hors de son second facteur) : secret, secret en attente et codes
|
||||
de récupération sont supprimés. Les clés WebAuthn éventuelles sont
|
||||
conservées et gardent alors le MFA actif. Le secret n'est jamais
|
||||
exposé — ni avant, ni après."""
|
||||
user = _compte_ou_404(username)
|
||||
webauthn_restantes = len(user.get("webauthn_credentials") or [])
|
||||
updates: dict = {
|
||||
"mfa_secret": None,
|
||||
"mfa_secret_pending": None,
|
||||
"mfa_recovery_codes": [],
|
||||
}
|
||||
if webauthn_restantes:
|
||||
updates["mfa_method"] = "webauthn"
|
||||
else:
|
||||
updates["mfa_enabled"] = False
|
||||
updates["mfa_method"] = None
|
||||
updated = update_user(username, updates)
|
||||
_audit(request, acteur, "mfa_reinitialise", username,
|
||||
{"webauthn_conserves": webauthn_restantes})
|
||||
return _profil(updated)
|
||||
|
||||
|
||||
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
|
||||
|
||||
@router.get("/{username}/sessions")
|
||||
async def lister_sessions(username: str,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Liste les sessions draco actives d'un compte.
|
||||
|
||||
Une session par ``jti`` émis et non expiré (registre des émissions
|
||||
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
|
||||
un ``jti`` déjà présent dans la liste des révocations et
|
||||
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
|
||||
"""
|
||||
user = _compte_ou_404(username)
|
||||
sessions = [{
|
||||
"jti": s["jti"],
|
||||
"exp": s["exp"],
|
||||
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
|
||||
"revoquee": s["revoquee"],
|
||||
} for s in draco_session.issued_sessions_for_user(
|
||||
_identifiant_sessions(user))]
|
||||
return {"username": username, "sessions": sessions}
|
||||
|
||||
|
||||
@router.post("/{username}/sessions/{jti}/revoquer")
|
||||
async def revoquer_session(username: str, jti: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Révoque une seule session draco d'un compte, par son ``jti``.
|
||||
|
||||
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
|
||||
compte : une session d'un autre utilisateur n'est jamais révoquée
|
||||
par cette route (aucune révocation cross-utilisateur).
|
||||
"""
|
||||
user = _compte_ou_404(username)
|
||||
if not draco_session.revoke_one_for_user(
|
||||
_identifiant_sessions(user), jti):
|
||||
raise HTTPException(404, "Session introuvable")
|
||||
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
|
||||
return {"jti": jti, "revoquee": True}
|
||||
|
||||
|
||||
@router.post("/{username}/sessions/revoquer-toutes")
|
||||
async def revoquer_toutes_sessions(
|
||||
username: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Révoque toutes les sessions draco actives d'un compte.
|
||||
|
||||
C'est la « déconnexion globale » : contrairement à la
|
||||
désactivation, le compte reste actif — seule l'émission d'une
|
||||
nouvelle session (nouvelle connexion) rouvre l'accès.
|
||||
"""
|
||||
user = _compte_ou_404(username)
|
||||
nb_jti = _revoquer_sessions(user)
|
||||
_audit(request, acteur, "sessions_revoquees", username,
|
||||
{"jti_revoques": nb_jti})
|
||||
return {"revoquees": nb_jti}
|
||||
@@ -44,7 +44,7 @@ from backend.schemas import (
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
from backend.services.vaults import browse_directory, chemin_exclu, get_vault_root
|
||||
from backend.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
@@ -171,6 +171,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
@@ -243,6 +246,8 @@ def api_file_xlsx_dashboard(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
_vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(_vault_root, path)
|
||||
if not file_path.is_file():
|
||||
@@ -311,6 +316,9 @@ def api_file_xlsx_sheet(
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
@@ -366,6 +374,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
|
||||
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
|
||||
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
|
||||
#
|
||||
# GET /sso/logout?return_to=<url> : symétrique de /sso/authorize —
|
||||
# point d'entrée navigable de la déconnexion globale (BUG-122,
|
||||
# fusion #141). Les autres applications y naviguent depuis leur menu
|
||||
# compte ; la déconnexion exécutée est celle de POST /api/auth/logout
|
||||
# (logique partagée : jetons natifs révoqués, jti draco révoqué,
|
||||
# cookie effacé sur le domaine parent), puis redirection vers le
|
||||
# return_to validé (même liste blanche ; repli « / »).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from backend import draco_session
|
||||
from backend.auth.router import apply_global_logout
|
||||
|
||||
router = APIRouter(tags=["SSO"])
|
||||
|
||||
@@ -34,6 +43,14 @@ async def sso_authorize(return_to: str = ""):
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
@router.get("/sso/logout")
|
||||
async def sso_logout(request: Request, return_to: str = ""):
|
||||
target = draco_session.validate_return_to(return_to)
|
||||
response = RedirectResponse(url=target or "/", status_code=302)
|
||||
apply_global_logout(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/api/sso/revocations")
|
||||
async def sso_revocations(request: Request):
|
||||
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
|
||||
|
||||
@@ -609,6 +609,7 @@ class VaultInfo(BaseModel):
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
||||
readonly: bool = Field(default=False, description="True when the vault is declared read-only (DIR_N_READONLY) — mutations are refused and the UI hides write actions")
|
||||
|
||||
|
||||
class BookmarkToggleRequest(BaseModel):
|
||||
|
||||
@@ -7,13 +7,30 @@ from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.services.vaults import chemin_exclu, get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.files")
|
||||
|
||||
|
||||
def _ensure_not_excluded(vault_name: str, path: str) -> None:
|
||||
"""Raise ``not_found`` (404) when *path* is excluded in this vault.
|
||||
|
||||
Un chemin exclu (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) est
|
||||
indiscernable d'un chemin inexistant : même erreur, aucun détail
|
||||
sur la règle qui l'a caché.
|
||||
"""
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
|
||||
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
|
||||
"""Return the raw text content of a vault file (no redaction)."""
|
||||
_ensure_not_excluded(vault_name, path)
|
||||
root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
@@ -56,6 +73,7 @@ def read_file_text(
|
||||
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
|
||||
error (500).
|
||||
"""
|
||||
_ensure_not_excluded(vault_name, path)
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path)
|
||||
|
||||
|
||||
@@ -69,8 +69,18 @@ def _rel(root: Path, path: Path) -> str:
|
||||
return str(path.relative_to(root)).replace("\\", "/")
|
||||
|
||||
|
||||
def _ensure_writable(root: Path) -> None:
|
||||
"""Raise ``read_only`` (403) when the vault root is not writable."""
|
||||
def _ensure_writable(root: Path, vault_name: str | None = None) -> None:
|
||||
"""Raise ``read_only`` (403) when the vault must not be mutated.
|
||||
|
||||
Two sources, same error: the vault is **declared** read-only
|
||||
(``DIR_N_READONLY``, via :func:`vault_readonly`) or its root is
|
||||
not writable on the filesystem (``:ro`` mount).
|
||||
"""
|
||||
if vault_name is not None:
|
||||
from backend.services.vaults import vault_readonly
|
||||
|
||||
if vault_readonly(vault_name):
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
if not os.access(root, os.W_OK):
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
|
||||
@@ -139,7 +149,7 @@ def create_file(
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path)
|
||||
|
||||
@@ -190,7 +200,7 @@ def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> d
|
||||
``already_exists`` (409) when *exist_ok* is False.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if dir_path.exists():
|
||||
@@ -234,7 +244,7 @@ def edit_file(
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -470,7 +480,7 @@ def edit_xlsx_cells(
|
||||
reference or value.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -592,7 +602,7 @@ def mutate_xlsx_structure(
|
||||
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1000,7 +1010,7 @@ def mutate_xlsx_style(
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1255,7 +1265,7 @@ def save_csv_cells(
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1341,7 +1351,7 @@ def append_to_file(
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1382,7 +1392,7 @@ def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]:
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
old_path = resolve_safe_path(root, path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_file():
|
||||
@@ -1424,7 +1434,7 @@ def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any
|
||||
``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
old_path = resolve_safe_path(root, path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_dir():
|
||||
@@ -1467,7 +1477,7 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
@@ -1546,7 +1556,7 @@ def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "")
|
||||
destination is inside the source directory.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
@@ -1764,7 +1774,7 @@ def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str,
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1797,7 +1807,7 @@ def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> d
|
||||
``invalid_arguments`` (400) when targeting the vault root.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
@@ -1855,7 +1865,7 @@ def restore_backup(
|
||||
or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
backup_dir = get_backup_dir(vault_name, path)
|
||||
@@ -1931,7 +1941,7 @@ def save_raw_file(
|
||||
Dict with ``success``, ``vault``, ``path``, and ``size``.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path, allow_images=True, allow_docs=allow_docs)
|
||||
|
||||
@@ -1980,7 +1990,7 @@ def batch_upload_files(
|
||||
and ``errors`` (list of error dicts).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
clean_target = (target_dir or "").strip().strip("/\\")
|
||||
uploaded: list[str] = []
|
||||
|
||||
@@ -215,8 +215,9 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import get_vault_data, path_index
|
||||
from backend.services.vaults import chemin_exclu
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
entries = [e for e in path_index.get(vault, []) if not chemin_exclu(vault, e["path"])]
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
data = get_vault_data(vault)
|
||||
if data:
|
||||
|
||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,6 +16,68 @@ from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
|
||||
|
||||
def chemin_exclu_dans_config(config: dict[str, Any] | None, rel_path: str) -> bool:
|
||||
"""True si *rel_path* est exclu par la config d'exclusions d'un vault.
|
||||
|
||||
Deux mécanismes, lus dans le dict ``config`` du vault (alimenté par
|
||||
``DIR_N_IGNORED_DIRS`` / ``DIR_N_IGNORED_PATHS``, vaults DIR seulement) :
|
||||
|
||||
- ``ignoredDirs`` : le nom d'un segment du chemin (à toute profondeur)
|
||||
figure dans l'ensemble — ex. ``.home`` cache ``a/.home/x`` ;
|
||||
- ``ignoredPaths`` : motifs fnmatch testés contre le chemin relatif
|
||||
POSIX **et chacun de ses préfixes** — un dossier qui matche cache
|
||||
donc tout son sous-arbre, ex. ``Executeurs/*/data`` cache aussi
|
||||
``Executeurs/Hermes/data/config.md``.
|
||||
|
||||
Tolérant : config absente/vide ou chemin vide → False.
|
||||
"""
|
||||
if not config or not rel_path:
|
||||
return False
|
||||
rel = str(rel_path).replace("\\", "/").strip("/")
|
||||
if not rel:
|
||||
return False
|
||||
segments = rel.split("/")
|
||||
ignored_dirs = config.get("ignoredDirs") or []
|
||||
if any(seg in ignored_dirs for seg in segments):
|
||||
return True
|
||||
prefixes = ["/".join(segments[:i]) for i in range(1, len(segments) + 1)]
|
||||
for pattern in config.get("ignoredPaths") or []:
|
||||
pat = str(pattern).replace("\\", "/").strip("/")
|
||||
if pat and any(fnmatch.fnmatchcase(prefix, pat) for prefix in prefixes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def chemin_exclu(vault_name: str, rel_path: str) -> bool:
|
||||
"""True si *rel_path* est exclu dans le vault *vault_name*.
|
||||
|
||||
Lit la config du vault via l'index (``get_vault_data``). Tolérant :
|
||||
vault inconnu ou sans config d'exclusions → False.
|
||||
"""
|
||||
try:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
return chemin_exclu_dans_config(data.get("config") or {}, rel_path)
|
||||
|
||||
|
||||
def vault_readonly(vault_name: str) -> bool:
|
||||
"""True si le vault est déclaré en lecture seule (DIR_N_READONLY)."""
|
||||
try:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
return bool((data.get("config") or {}).get("readonly", False))
|
||||
|
||||
|
||||
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the vaults *user* may access, with summary metadata."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
@@ -29,6 +92,7 @@ def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"file_count": len(data.get("files", [])),
|
||||
"tag_count": len(data.get("tags", {})),
|
||||
"type": data.get("config", {}).get("type", "VAULT"),
|
||||
"readonly": bool(data.get("config", {}).get("readonly", False)),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -58,7 +122,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path) if path else root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
if not target.exists() or (path and chemin_exclu(vault_name, path)):
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
@@ -74,12 +138,15 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||
if chemin_exclu(vault_name, rel):
|
||||
continue
|
||||
if entry.is_dir():
|
||||
# Count only direct children (files and subdirs) for performance.
|
||||
try:
|
||||
file_count = sum(
|
||||
1 for child in entry.iterdir()
|
||||
if (not hide_hidden or not child.name.startswith("."))
|
||||
and not chemin_exclu(vault_name, f"{rel}/{child.name}")
|
||||
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||
or child.is_dir())
|
||||
)
|
||||
@@ -200,6 +267,9 @@ def list_all_files(
|
||||
stat = entry.stat()
|
||||
rel_path = str(entry.relative_to(root)).replace("\\", "/")
|
||||
|
||||
if chemin_exclu(vault_name, rel_path):
|
||||
continue
|
||||
|
||||
if recursive and dir_prefix:
|
||||
try:
|
||||
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
|
||||
|
||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.81.0"
|
||||
version = "2.86.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.81.0"
|
||||
version = "2.86.3"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.81.0",
|
||||
"version": "2.86.3",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -73,6 +73,16 @@ configurée (sinon 503 `kek_absente`).
|
||||
SSO : une session utilisateur, même admin, ne distribue jamais
|
||||
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
|
||||
distribution par session (voir §6).
|
||||
- Révélation manuelle (panneau détail, hors distribution) : `POST
|
||||
/api/coffres/{slug}/secrets/{nom}/reveler` rend la valeur au
|
||||
gestionnaire du coffre authentifié par sa session (propriétaire,
|
||||
ou admin sur coffre d'instance), pour l'afficher ou la copier.
|
||||
Ce n'est pas une distribution : aucun octroi n'est créé, aucun
|
||||
consommateur ne reçoit la valeur, chaque révélation est auditée
|
||||
(`revelation_manuelle`) et met à jour la dernière utilisation.
|
||||
Un lecteur par octroi utilisateur (métadonnées visibles) reçoit
|
||||
403. L'ancienne copie manuelle par consommateur temporaire a
|
||||
été supprimée (elle échouait : 403 octroi_absent).
|
||||
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||
**sans modification du coffre** et sans toucher à ses secrets.
|
||||
|
||||
|
||||
@@ -227,6 +227,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
|
||||
| *BUG-119* | Panneau Spark : le « mode console » (surimpression iframe de la console du service d'agents) est bloqué par le navigateur (« This content is blocked ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Panneau Spark → « Console ⤢ » : l'iframe reste sur la page d'erreur du navigateur | `frame-src 'self' blob: https://agent.dracodev.net` dans la CSP servie ; test `tests/test_security_headers.py::test_csp_frame_src_allows_agent_console` | Cause : `frame-src` n'autorisait pas l'origine du service d'agents, pourtant déjà présente dans `connect-src` (le panneau texte fonctionnait). Aucune autre directive modifiée. Redéploiement Docker requis |
|
||||
| *BUG-120* | Menu inter-apps : sur l'environnement dev, les URL sœurs servies (`window.DRACO_URLS` : ObsiGate, Flowdeck, TikTak, Kanbante) restent en noms **prod** alors que l'URL de l'agent est bien en `-dev` | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/draco_config.py` (`app_url`, `_suffixe_environnement`) | Sur og-dev : `window.DRACO_URLS` donne `agent: https://agent-dev.dracodev.net` mais `tiktak: https://tiktak.dracodev.net` (prod) — le `.env` dev ne déclare pas les quatre `DRACO_<APP>_URL` | `backend/draco_config.py` : résolution sœur en 3 temps — explicite, puis **dérivation d'environnement** depuis l'hôte de `agent_url()` (`agent<SUFFIXE>.dracodev.net` → `https://<slug><SUFFIXE>.dracodev.net`, slugs `og`/`flowdeck`/`tiktak`/`kanbante` ; hors motif → rien), puis défaut officiel ; Gitea exclue. Tests `tests/test_draco_config.py` (+9 : dérivation `-dev` via `AGENT_EVENEMENTS_URL` et via `DRACO_AGENT_URL`, priorité de l'explicite, 5 hôtes hors motif, suffixe vide, injection) | Suite de #223 : seule l'URL de l'agent avait une dérivation de repli (`AGENT_EVENEMENTS_URL`) ; les sœurs retombaient sur les défauts officiels. Correctif de code demandé par Bruno : l'environnement se déduit de l'agent déjà configuré, sans variable supplémentaire. Redéploiement Docker requis |
|
||||
| *BUG-122* | Déconnexion du menu compte inerte dans les applications sœurs : la barre commune relaie `deconnexion` à l'hôte, mais la déconnexion globale d'ObsiGate (ACC-3.1) n'est appelable qu'en same-origin (`POST /api/auth/logout`) — aucun point d'entrée navigable pour les autres hôtes | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/routers/sso.py` (`GET /sso/logout`), `backend/auth/router.py` (`apply_global_logout`) | Depuis la console Spark (ou Flowdeck/TikTak/ShaarIt/Hub) : menu compte → « Déconnexion » → rien ne se passe, la session draco reste vivante (vérifié en navigateur réel : cookie présent, `/api/v1/me` toujours 200) | Nouvelle route `GET /sso/logout?return_to=` : exécute la déconnexion globale (logique partagée `apply_global_logout` : jetons natifs refresh/access révoqués, `jti` draco révoqué, cookie effacé sur le domaine parent) puis redirige vers le `return_to` validé (liste blanche https + *.dracodev.net, repli `/`). Tests `tests/test_draco_session.py` (+3) | Signalé par Bruno le 2026-11-06 (capture console), carte fusion #141 ; les hôtes branchent leur action `deconnexion` sur cette route (PR sœurs). BUG-121 reste réservé au bug de couleur « Secrets » du vault Spark (proposé, non ordonné). Redéploiement Docker requis |
|
||||
| *BUG-123* | Aller-retour SSO non refermé quand la session ObsiGate existe déjà : `GET /sso/authorize?return_to=…` dépose l'utilisateur sur `/?return_to=…`, mais le retour n'est honoré que par `AuthManager.saveToken()` (login frais, MFA, WebAuthn) — les chemins de restauration de session d'`initAuth()` (jeton encore valide en sessionStorage, ou refresh silencieux au démarrage) affichent l'accueil sans jamais suivre le `return_to` : l'utilisateur reste chez ObsiGate et, revenu manuellement à l'application sœur, n'y a toujours aucune session locale | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/auth.js` (`initAuth`, `_honorReturnTo`) | Navigateur avec session ObsiGate active → Kanbante → « Se connecter avec ObsiGate (SSO) » → retour automatique attendu dans Kanbante, connecté ; constaté en pile locale complète (chaîne d'URL journalisée) : arrivée sur l'accueil ObsiGate, retour jamais suivi | `_honorReturnTo()` retourne si la redirection a eu lieu ; les deux chemins de restauration d'`initAuth()` l'invoquent avant `showApp()` (les refresh en cours de session restent sans redirection). Garde : `tests/test_draco_barre.py` ; preuve navigateur complète du chantier (pile locale aux noms publics) | Signalé par Bruno le 2026-11-08 (test réel, compte « testeur » créé au Hub) ; le sous-cas login frais refermait déjà la boucle (vérifié). Fiche `docs/features/entree-sso-bug-123-124.md`. Redéploiement Docker requis |
|
||||
| *BUG-124* | Nom du compte absent du menu de la barre commune juste après le login : `renderUserSection()` met à jour le bloc latéral et le drapeau admin de la barre, mais ne pousse jamais le nom — le seul poseur d'identité de la barre est le fetch d'amorçage au `DOMContentLoaded`, limité aux sessions déjà présentes au chargement. Après un login SPA sans rechargement : avatar « ✦ » et en-tête « Compte » jusqu'au rechargement forcé | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/auth.js` (`renderUserSection`), `frontend/index.html`, `frontend/draco-barre.js` (vendange commons v0.7.0) | Se connecter dans la SPA puis ouvrir le menu compte de la barre sans recharger : en-tête « Compte » au lieu du nom (reproduit en pile locale, capture) | Barre commune v0.7.0 re-vendangée verbatim (nouvelle API `DRACO_BAR.setUser`, PR dracodev-commons #15) ; `renderUserSection()` appelle `setUser(name)` / `setUser(null)`, le fetch d'amorçage d'`index.html` pousse aussi le nom par `setUser`. Garde : `tests/test_draco_barre.py` | Signalé par Bruno le 2026-11-08 (captures). Fiche `docs/features/entree-sso-bug-123-124.md`. Redéploiement Docker requis |
|
||||
| *BUG-125* | Aller-retour SSO : le retour est honoré mais le pont de l'application sœur ne reçoit aucun cookie `draco_session` quand les cookies `access_token` et `draco_session` ont disparu alors que la session semble encore valide en `sessionStorage` — le chemin court d'`initAuth()` (BUG-123) repart sans aucun échange serveur, donc sans la réémission du cookie draco par `DracoRenewalMiddleware` ; côté sœur : jeton absent au pont = redirection vers le formulaire **nu, sans message** (à distinguer de l'échec de résolution, qui affiche le bandeau `draco=echec`, et du rôle « aucun », qui affiche une page 403) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/auth.js` (`initAuth`) | Pile « conteneur dev réel » : login chez ObsiGate, suppression des cookies `draco_session` + `access_token` (cookie refresh conservé), Kanbante → clic SSO → la requête du pont part **sans cookie draco** (journalisée) → formulaire Kanbante nu ; un second clic, lui, aboutit (le premier passage a provoqué la réémission) | À l'arrivée d'un `/sso/authorize` (`return_to` présent), le chemin court est écarté : `initAuth()` passe par le refresh silencieux + `/api/auth/me`, dont la réponse traverse le middleware de renouvellement qui réémet le cookie draco manquant ou invalide **avant** que le retour soit honoré ; si le refresh échoue, écran de connexion comme avant (le `return_to` reste dans l'URL). Garde : `tests/test_draco_barre.py` (`test_retour_sso_refresh_force_bug_125`) ; preuve navigateur : la variante fautive de la pile retourne connectée après correctif | Signalé par Bruno le 2026-11-08 (retest réel après BUG-123 + retour sur le bon hôte) ; diagnostic complet (formes d'échec du pont cartographiées en pile) dans le dossier `preuve-retour-sso-hote`. Fiche `docs/features/retour-sso-cookie-draco-bug-125.md`. Redéploiement Docker requis |
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -361,6 +365,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-23 | BUG-119 | Correction | `backend/main.py`, `tests/test_security_headers.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | **BUG-119 — le « mode console » du panneau Spark V3 (#222) était bloqué par le navigateur** (« This content is blocked ») : la console du service d'agents s'affiche dans une iframe `https://agent.dracodev.net`, mais la CSP servie par `SecurityHeadersMiddleware` limitait `frame-src` à `'self' blob:` — seul le panneau texte passait (`connect-src` autorisait déjà l'origine). Correctif strict : `frame-src 'self' blob: https://agent.dracodev.net`, toutes les autres directives inchangées à l'identique. Test de non-régression `test_csp_frame_src_allows_agent_console` (la CSP servie est analysée et la liste exacte des sources `frame-src` verrouillée). Vérifié : 100 passed (security_headers + csp_nonce + security_hardening + draco_barre + version), ruff propre sur les fichiers touchés (51 erreurs préexistantes ailleurs, inchangées), mypy 0 (120 fichiers) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-25 | BUG-120 (suite #223) | Correction | `backend/draco_config.py`, `tests/test_draco_config.py`, `.env.example`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/devprod-urls-config-223.md` | **BUG-120 — le menu inter-apps de la dev pointait la prod** : `window.DRACO_URLS` servait l'agent en `-dev` mais les quatre sœurs en noms officiels, le `.env` dev ne déclarant pas les `DRACO_<APP>_URL`. Correctif dans `draco_config.app_url()` : après l'explicite, **dérivation d'environnement** — hôte de `agent_url()` de la forme `agent<SUFFIXE>.dracodev.net` sans port → sœur en `<slug><SUFFIXE>.dracodev.net` ; hors motif (autre domaine, LAN, IP, port) aucune dérivation ; Gitea jamais dérivée. Vérifié : `test_draco_config.py` 16 passed, suite complète **1668 passed / 9 skipped**, ruff 0 et mypy 0 sur les fichiers touchés (51 erreurs ruff préexistantes ailleurs, inchangées) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-11-06 | BUG-122 (fusion #141) | Correction | `backend/routers/sso.py`, `backend/auth/router.py`, `tests/test_draco_session.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/features/deconnexion-globale-bug-122.md`, `VERSION` (+ dérivés) | **BUG-122 — la déconnexion du menu compte ne terminait rien hors ObsiGate** : ajout de `GET /sso/logout`, point d'entrée navigable de la déconnexion globale (logique partagée avec `POST /api/auth/logout`). Vérifié : `test_draco_session.py` 34 passed, ruff 0 et mypy 0 sur les fichiers touchés | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-11-08 | BUG-123 + BUG-124 (chantier entrée SSO) | Correction | `frontend/js/auth.js`, `frontend/index.html`, `frontend/draco-barre.js`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/entree-sso-bug-123-124.md`, `VERSION` (+ dérivés) | **BUG-123 — aller-retour SSO refermé à la restauration de session** : `initAuth()` honore le `return_to` sur ses deux chemins de restauration (jeton valide, refresh silencieux au démarrage), comme le faisait déjà `saveToken` après un login frais. **BUG-124 — nom du compte dans la barre dès le login** : barre commune re-vendangée en v0.7.0 (`DRACO_BAR.setUser`, PR dracodev-commons #15), branchée dans `renderUserSection()` et le fetch d'amorçage. Chaînes prouvées en navigateur réel (pile locale aux noms publics, scénario de Bruno) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-11-08 | BUG-125 (suite entrée SSO) | Correction | `frontend/js/auth.js`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/retour-sso-cookie-draco-bug-125.md`, `VERSION` (+ dérivés) | **BUG-125 — retour SSO honoré mais pont sans cookie draco** : quand les cookies `access_token` et `draco_session` ont disparu alors que `sessionStorage` croit la session valide, le chemin court d'`initAuth()` repartait sans échange serveur — aucune réémission du cookie draco — et le pont de l'application sœur, sans jeton, déposait l'utilisateur devant son formulaire nu. Correctif : avec `return_to`, `initAuth()` force le refresh silencieux + `/api/auth/me` (réémission par `DracoRenewalMiddleware`) avant d'honorer le retour. Reproduit puis vérifié en navigateur réel dans la pile « conteneur dev réel » (requête du pont journalisée : sans cookie avant, avec cookie après) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
|
||||
> **Version :** 2.86.3 | **Dernière mise à jour :** 2026-11-08
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -30,6 +30,64 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 227. Hub HUB-22 — API machine des sessions draco (liste et révocation)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.0 (PR #68 fusionnée et release publiée — voir CHANGELOG ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/hub-sessions-machine-227.md](./features/hub-sessions-machine-227.md)
|
||||
- **Description :** vague C du plan Hub v2.1 (HUB-22) : le Hub doit lister
|
||||
les sessions actives d'un utilisateur et les révoquer une à une ou
|
||||
toutes, via le canal `jti` livré en HUB-13 (#226). Trois routes sous
|
||||
la même garde machine (`require_machine_admin`) et la même
|
||||
journalisation d'audit que les routes comptes existantes :
|
||||
`GET /api/admin/machine/comptes/{username}/sessions`,
|
||||
`POST …/sessions/{jti}/revoquer` (jamais de révocation
|
||||
cross-utilisateur) et `POST …/sessions/revoquer-toutes`
|
||||
(déconnexion globale, sans désactiver le compte).
|
||||
- **Sous-tâches :**
|
||||
- [x] `issued_sessions_for_user` + `revoke_one_for_user` (`backend/draco_session.py`)
|
||||
- [x] Trois routes sessions (`backend/routers/comptes_machine.py`)
|
||||
- [x] Tests étendus (`tests/test_comptes_machine.py`, `tests/test_draco_session.py`)
|
||||
### 228. Hub HUB-19 — propagation des rôles du Hub à ObsiGate (consommatrice)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.1 (voir CHANGELOG ; branche rebasée sur la 2.85.0 avant fusion ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/hub-roles-obsigate-228.md](./features/hub-roles-obsigate-228.md)
|
||||
- **Description :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) : le
|
||||
pilote ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate —
|
||||
à la fois émettrice et consommatrice du SSO — le consomme à son tour.
|
||||
À l'ouverture de session draco, consultation de
|
||||
`GET {hub}/api/roles/obsigate` (Cookie relayé, délai 2 s), cache
|
||||
mémoire 60 s par `sub` (horodatage posé avant l'appel). Niveau connu
|
||||
`aucun` → session draco refusée (réponse explicite en français) ;
|
||||
routes `/api/auth/admin/*` authentifiées par le canal draco :
|
||||
niveau `administrateur` exigé quand le rôle est connu. Toute erreur
|
||||
du Hub → rôle inconnu → comportement actuel. L'authentification
|
||||
native d'ObsiGate (émettrice, accès de secours) n'est jamais touchée ;
|
||||
validation locale du jeton, émission et révocations inchangées,
|
||||
aucun rôle dans le jeton.
|
||||
- **Sous-tâches :**
|
||||
- [x] Client `backend/hub_roles.py` (contrat D3(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
|
||||
- [x] Refus « aucun » dans `draco_session.user_for_draco` + réponse explicite de `require_auth`
|
||||
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
|
||||
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
|
||||
|
||||
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/hub-api-admin-comptes-226.md](./features/hub-api-admin-comptes-226.md)
|
||||
- **Description :** carte fusion #127 (plan Hub v2 §2.2, D1(a)/D2(a)) : le Hub
|
||||
administre les comptes dont ObsiGate reste le magasin, via
|
||||
`/api/admin/machine/comptes` (liste, profil étendu, création,
|
||||
modification, désactivation/réactivation avec révocation des `jti`
|
||||
draco, suppression, réinitialisation du mot de passe avec changement
|
||||
forcé `must_change_password`, réinitialisation du MFA), protégée par
|
||||
la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` — jamais par une
|
||||
session. Chaque action est journalisée dans `data/audit.log`.
|
||||
- **Sous-tâches :**
|
||||
- [x] Routes machine lecture/écriture + garde par clé dédiée (`backend/routers/comptes_machine.py`)
|
||||
- [x] Registre des émissions draco + `revoke_all_for_user` (`backend/draco_session.py`)
|
||||
- [x] Champ `must_change_password` (modèle, connexion, `/me`, changement de mot de passe)
|
||||
- [x] Journalisation `log_compte_admin` (`backend/audit.py`) + tests `tests/test_comptes_machine.py`
|
||||
|
||||
### 225. Fusion — panneau Spark : version du service d'agents visible en mode sidebar
|
||||
|
||||
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-26 | **Statut :** ✅ livré le 2026-10-26 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
|
||||
@@ -1169,6 +1227,9 @@
|
||||
| BUG-119 | Panneau Spark — « mode console » bloqué par la CSP (`frame-src` sans l'origine du service d'agents) | 2.77.1 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||
| BUG-120 | Menu inter-apps — les URL sœurs (`window.DRACO_URLS`) dérivent l'environnement de l'hôte de l'agent (`-dev` en dev), explicite prioritaire, Gitea inchangée (suite de #223) | 2.78.2 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||
| 225 | Panneau Spark — version du service d'agents visible en mode sidebar (pied du panneau, lue en direct via `GET /healthz`) | 2.79.0 | [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md) |
|
||||
| BUG-123 | Aller-retour SSO — le `return_to` de `/sso/authorize` est honoré aussi après restauration de session au démarrage (`initAuth`), pas seulement après un login frais | Unreleased | [features/entree-sso-bug-123-124.md](./features/entree-sso-bug-123-124.md) |
|
||||
| BUG-124 | Barre commune v0.7.0 — `DRACO_BAR.setUser` branché dans `renderUserSection()` : le nom du compte s'affiche dans le menu compte dès le login, sans rechargement | Unreleased | [features/entree-sso-bug-123-124.md](./features/entree-sso-bug-123-124.md) |
|
||||
| BUG-125 | Aller-retour SSO — à l'arrivée d'un `/sso/authorize`, `initAuth` force le refresh silencieux + `/api/auth/me` pour que le cookie `draco_session` soit réémis avant que le retour soit honoré (pont sœur sans jeton = formulaire nu) | Unreleased | [features/retour-sso-cookie-draco-bug-125.md](./features/retour-sso-cookie-draco-bug-125.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# BUG-122 — Déconnexion globale navigable : `GET /sso/logout`
|
||||
|
||||
**Carte :** fusion #141 (signalement de Bruno, 2026-11-06, capture
|
||||
depuis la console Spark). **Version :** 2.86.0. **Prérequis :** ACC-3.1
|
||||
(#216) — la déconnexion globale existe déjà dans
|
||||
`POST /api/auth/logout`.
|
||||
|
||||
## Constat
|
||||
|
||||
Le bouton « Déconnexion » du menu compte de la barre commune
|
||||
(dracodev-commons) est une **action relayée à l'hôte** (`cfg.onAction`,
|
||||
sinon événement `draco:bar`) : la barre ne déconnecte jamais
|
||||
elle-même. Or :
|
||||
|
||||
- console Spark, ObsiGate, Flowdeck, TikTak, ShaarIt et Hub ne
|
||||
branchaient **aucune** action `deconnexion` (clic sans effet,
|
||||
reproduit en navigateur réel sur la console : session draco intacte
|
||||
après le clic) ;
|
||||
- Kanbante, seul branché, ne détruisait que sa session locale — la
|
||||
session SSO survivait ;
|
||||
- et de toute façon, aucun hôte n'aurait pu terminer la session
|
||||
globale : `POST /api/auth/logout` n'est appelable qu'en same-origin
|
||||
depuis l'interface d'ObsiGate. Il manquait le point d'entrée
|
||||
navigable, symétrique de `GET /sso/authorize`.
|
||||
|
||||
## Livraison (cette PR, côté émettrice)
|
||||
|
||||
| Méthode | Chemin | Effet |
|
||||
|---|---|---|
|
||||
| GET | `/sso/logout?return_to=<url>` | Déconnexion globale, puis redirection 302 vers le `return_to` validé (repli « / ») |
|
||||
|
||||
- La logique de `POST /api/auth/logout` est extraite dans
|
||||
`apply_global_logout(request, response)` (`backend/auth/router.py`)
|
||||
et partagée par les deux entrées : révocation des jetons natifs
|
||||
refresh/access, révocation du `jti` du cookie `draco_session`,
|
||||
effacement des cookies (dont `draco_session` sur le domaine parent).
|
||||
- `return_to` passe par la liste blanche existante
|
||||
(`draco_session.validate_return_to` : https + `dracodev.net` ou
|
||||
sous-domaine) — aucun relais ouvert.
|
||||
- Idempotente : sans session, la route redirige quand même (les hôtes
|
||||
y naviguent même si leur session a déjà expiré).
|
||||
- La barre d'ObsiGate elle-même branche son action `deconnexion` sur
|
||||
cette route (`frontend/index.html`, retour à sa racine) — garde :
|
||||
`tests/test_draco_barre.py::test_barre_deconnexion_branchee`.
|
||||
|
||||
## Côté hôtes (PR sœurs, carte fusion #141)
|
||||
|
||||
Chaque hôte branche son action `deconnexion` : destruction de sa
|
||||
session locale d'abord (Flowdeck, TikTak, Kanbante, ShaarIt), puis
|
||||
navigation vers `<émettrice>/sso/logout?return_to=<sa base>`. La
|
||||
console et le Hub (session draco seule) naviguent directement.
|
||||
Après le parcours complet, « Déconnexion » termine **la session de la
|
||||
suite entière** et la session locale de l'hôte courant.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_draco_session.py` (+3) : révocation du `jti` + redirection
|
||||
vers le `return_to` + cookie effacé (`Max-Age=0`), liste blanche
|
||||
(relais refusé, repli « / »), idempotence sans session.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Entrée SSO de bout en bout — BUG-123 + BUG-124
|
||||
|
||||
Chantier « entrée SSO de bout en bout » (écarts constatés par Bruno en
|
||||
test réel le 2026-11-08, compte « testeur » créé au Hub) : deux des
|
||||
trois écarts tenaient à ObsiGate. Diagnostic mené en navigateur réel
|
||||
dans une pile locale complète servie sous les noms publics
|
||||
(`*-dev.dracodev.net`, TLS local), chaîne d'URL et de cookies
|
||||
journalisée à chaque saut — dossier de preuves `preuve-entree-sso`.
|
||||
|
||||
## BUG-123 — L'aller-retour SSO ne se referme pas (session existante)
|
||||
|
||||
**Chaîne observée (cas fautif)** : clic « Se connecter avec
|
||||
ObsiGate (SSO) » dans l'application sœur → `GET
|
||||
/sso/authorize?return_to=<app>/auth/draco` → `302 /?return_to=…` →
|
||||
la SPA restaure la session (`initAuth` : jeton encore valide, ou
|
||||
refresh silencieux via le cookie HttpOnly) et affiche l'accueil —
|
||||
**le `return_to` n'est jamais suivi**. L'utilisateur reste chez
|
||||
ObsiGate ; revenu manuellement à l'application, il n'y trouve
|
||||
aucune session locale (« toujours pas connecté »).
|
||||
|
||||
**Cause racine** : le retour n'était honoré qu'à un seul endroit,
|
||||
`AuthManager.saveToken()` → `_honorReturnTo()` — c'est-à-dire après
|
||||
un login frais, une MFA ou un WebAuthn. Les deux chemins de
|
||||
restauration d'`initAuth()` ne passaient ni par `saveToken` ni par
|
||||
`_honorReturnTo`. Le sous-cas « login frais » refermait la boucle
|
||||
correctement (vérifié dans la même pile) — d'où un bug invisible
|
||||
pour qui teste en navigateur vierge sans session centrale.
|
||||
|
||||
**Correctif** (`frontend/js/auth.js`) :
|
||||
|
||||
- `_honorReturnTo()` retourne `true` quand il redirige (même liste
|
||||
blanche inchangée : https + `*.dracodev.net`, jamais l'hôte
|
||||
courant — re-validation cliente en plus de celle du serveur).
|
||||
- `initAuth()` invoque `_honorReturnTo()` sur ses deux chemins de
|
||||
restauration réussie, avant `showApp()`.
|
||||
- Les refresh silencieux **en cours de session** (`refreshAccessToken`
|
||||
appelé par l'intercepteur) n'honorent toujours pas le retour :
|
||||
une page déjà ouverte ne doit jamais être redirigée.
|
||||
|
||||
## BUG-124 — Le nom du compte absent du menu de la barre après login
|
||||
|
||||
**Constat** : juste après le login, le menu compte de la barre
|
||||
commune affiche l'en-tête générique « Compte » et l'avatar neutre
|
||||
« ✦ » ; un rechargement forcé fait apparaître le nom.
|
||||
|
||||
**Cause racine** : la barre résout le nom dans `cfg.user`, sinon
|
||||
dans le `title` de l'avatar. Chez ObsiGate, le seul poseur du
|
||||
`title` était le fetch d'amorçage d'`index.html` au
|
||||
`DOMContentLoaded` — limité aux sessions **déjà présentes au
|
||||
chargement**. `renderUserSection()` (`js/auth.js`), appelée pour
|
||||
toutes les sessions établies (login, restauration), mettait à jour
|
||||
le bloc latéral et le drapeau admin de la barre (`setAdmin`,
|
||||
v0.6.0/fusion #142) mais jamais le nom : la barre n'avait aucune
|
||||
API pour le recevoir.
|
||||
|
||||
**Correctif** :
|
||||
|
||||
- dracodev-commons **v0.7.0** (PR #15) : API publique
|
||||
`DRACO_BAR.setUser(user)` — même contrat de valeur que `cfg.user`
|
||||
(chaîne, ou objet `display_name` / `name` / `username`) ;
|
||||
resynchronise l'avatar (initiale, infobulle, aria-label) et
|
||||
l'en-tête du menu compte immédiatement ; `setUser(null)` rétablit
|
||||
l'état neutre. Barre re-vendangée verbatim dans
|
||||
`frontend/draco-barre.js`.
|
||||
- `renderUserSection()` appelle `setUser(name)` pour toute session
|
||||
établie et `setUser(null)` sans identité ; le fetch d'amorçage
|
||||
d'`index.html` pousse aussi le nom par `setUser` (le `title` seul
|
||||
ne resynchronisait ni l'aria-label ni un menu déjà construit).
|
||||
|
||||
## Vérifications
|
||||
|
||||
- `tests/test_draco_barre.py::test_barre_v070_setuser_et_retour_sso_restauration`
|
||||
(contrats épinglés) + suites draco/session/version vertes.
|
||||
- `node ui/barre-apps/tests/barre-apps.test.js` côté commons :
|
||||
58 tests, 284 assertions (nouveau test setUser).
|
||||
- Preuve navigateur de bout en bout (scénario de Bruno, contexte
|
||||
vierge) : voir le rapport du chantier et `preuve-entree-sso/`.
|
||||
@@ -0,0 +1,105 @@
|
||||
# #226 — HUB-13 : API d'administration des comptes (machine-à-machine)
|
||||
|
||||
**Carte :** fusion #127 (HUB-13, plan Hub v2 §2.2 et §4 vague A ; décisions
|
||||
D1(a) — magasin des comptes chez ObsiGate — et D2(a) — ObsiGate émettrice).
|
||||
**Version :** 2.84.0.
|
||||
|
||||
## Objet
|
||||
|
||||
Le Hub administre les comptes de la suite depuis son interface
|
||||
d'administration ; ObsiGate reste le magasin des comptes. Cette API est
|
||||
le bras armé de cette interface : administration complète des comptes,
|
||||
en lecture et en écriture, réservée aux appels machine-à-machine.
|
||||
|
||||
## Routes — `/api/admin/machine/comptes`
|
||||
|
||||
Emplacement choisi : l'espace d'administration existant (`/api/admin/…`),
|
||||
segment `machine` explicite. Il distingue ces routes de l'administration
|
||||
par session (`/api/auth/admin/users`, garde `require_admin`) : aucune
|
||||
session utilisateur, même administratrice, ne les ouvre.
|
||||
|
||||
| Méthode | Chemin | Effet |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/machine/comptes` | Liste des comptes (profils étendus) |
|
||||
| GET | `/api/admin/machine/comptes/{username}` | Profil étendu d'un compte |
|
||||
| POST | `/api/admin/machine/comptes` | Création (201 ; 409 si identifiant pris) |
|
||||
| PATCH | `/api/admin/machine/comptes/{username}` | Modification (nom affiché, rôle global, langue, vaults) |
|
||||
| POST | `/api/admin/machine/comptes/{username}/desactiver` | Désactivation + révocation des sessions |
|
||||
| POST | `/api/admin/machine/comptes/{username}/reactiver` | Réactivation |
|
||||
| DELETE | `/api/admin/machine/comptes/{username}` | Suppression + révocation des sessions |
|
||||
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mot-de-passe` | Nouveau mot de passe temporaire, changement forcé |
|
||||
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mfa` | Effacement de l'enrôlement TOTP |
|
||||
|
||||
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
|
||||
configurée), 409 conflit d'identifiant, 422 corps invalide.
|
||||
|
||||
## Protection
|
||||
|
||||
- Clé machine **dédiée** : `DRACO_ADMIN_MACHINE_KEY`, en-tête
|
||||
`X-Draco-Machine-Key` (même famille et même en-tête que
|
||||
`GET /api/sso/revocations`). Distincte de `DRACO_SSO_MACHINE_KEY` :
|
||||
l'écriture de comptes est plus sensible que la lecture des
|
||||
révocations (plan §3). Comparaison à temps constant.
|
||||
- Sans la variable, l'API est inerte (404 « Non configuré »).
|
||||
- Acteur consigné : en-tête facultatif `X-Draco-Actor` (défaut
|
||||
`service-machine`).
|
||||
|
||||
## Profil étendu
|
||||
|
||||
Liste blanche stricte : `id`, `username`, `display_name`, `language`,
|
||||
`avatar` (valeur du modèle — data-URL ou `null`, comme
|
||||
`GET /api/auth/me`) et `avatar_present`, `role`, `active`, `vaults`,
|
||||
`mfa_enabled` (oui/non), `mfa_method`, `must_change_password`,
|
||||
`created_at`, `last_login`. **Jamais** le haché du mot de passe, le
|
||||
secret TOTP, les codes de récupération ni les identifiants WebAuthn.
|
||||
|
||||
## Désactivation et sessions
|
||||
|
||||
- `active = false` → la connexion est refusée (401 uniforme, BUG-039)
|
||||
et plus aucun jeton n'est émis.
|
||||
- Les `jti` draco émis pour le compte sont révoqués via le mécanisme
|
||||
existant (`draco_session.revoke`, liste publiée par
|
||||
`GET /api/sso/revocations`). Pour les connaître, `draco_session`
|
||||
tient désormais un registre des émissions (`data/draco_issued.json`,
|
||||
`jti → exp` par `sub`, purgé à l'expiration) et
|
||||
`revoke_all_for_user(sub)`.
|
||||
- Le repère `password_changed_at` (BUG-028) est avancé : les jetons
|
||||
natifs déjà émis sont invalidés et ne ressuscitent pas à la
|
||||
réactivation. Les anciennes sessions draco restent révoquées après
|
||||
réactivation : une nouvelle connexion est nécessaire.
|
||||
|
||||
## Changement forcé du mot de passe
|
||||
|
||||
Nouveau champ du modèle utilisateur : `must_change_password` (booléen,
|
||||
défaut `false` — ajouté par `create_user`, aucun fichier à migrer :
|
||||
les comptes existants sans le champ valent `false`). Posé à la
|
||||
création machine et à la réinitialisation du mot de passe ; signalé
|
||||
dans la réponse de connexion et `GET /api/auth/me` ; effacé par
|
||||
`POST /api/auth/change-password`.
|
||||
|
||||
## Réinitialisation du MFA
|
||||
|
||||
Efface `mfa_secret`, `mfa_secret_pending` et `mfa_recovery_codes`.
|
||||
Les clés WebAuthn éventuelles sont conservées et gardent le MFA
|
||||
actif (méthode `webauthn`) ; sans elles, `mfa_enabled` passe à `false`.
|
||||
|
||||
## Journalisation
|
||||
|
||||
Chaque action est consignée dans le journal d'audit existant
|
||||
(`data/audit.log`, JSON lignes) via `backend.audit.log_compte_admin` :
|
||||
horodatage, `acteur` (identité de service), `action` (`compte_cree`,
|
||||
`compte_modifie`, `compte_desactive`, `compte_reactive`,
|
||||
`compte_supprime`, `mot_de_passe_reinitialise`, `mfa_reinitialise`),
|
||||
`cible` (compte visé), IP et détails non sensibles (champs modifiés,
|
||||
nombre de `jti` révoqués — jamais de mot de passe ni de secret).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_comptes_machine.py` : refus sans clé / mauvaise clé / clé
|
||||
SSO / session admin, API inerte non configurée, liste et profil étendu
|
||||
sans secret, création puis lecture et connexion, doublon 409,
|
||||
modification, désactivation (connexion refusée, session native
|
||||
refusée, `jti` draco révoqué et publié) puis réactivation,
|
||||
réinitialisation du mot de passe (ancien refusé, nouveau accepté,
|
||||
changement forcé signalé puis effacé au changement), réinitialisation
|
||||
MFA (connexion directe après), suppression, journalisation.
|
||||
@@ -0,0 +1,77 @@
|
||||
# #228 — HUB-19 : propagation des rôles du Hub à ObsiGate
|
||||
|
||||
**Carte :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) — le pilote
|
||||
ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate le consomme à
|
||||
son tour, comme hôte à la fois **émettrice** et **consommatrice** du SSO.
|
||||
**Version cible :** 2.85.1 (branche depuis le main 2.84.1 ; hypothèse : à
|
||||
rebaser après la PR « API sessions HUB-22 » — 2.85.0 — si elle fusionne
|
||||
d'abord).
|
||||
|
||||
## Contrat consommé (D3(a))
|
||||
|
||||
À l'ouverture de session draco — chez ObsiGate : la validation locale du
|
||||
cookie partagé, `draco_session.user_for_draco()` (ACC-3.2, #217), appelée
|
||||
par le repli draco de `get_current_user` — consultation du registre :
|
||||
|
||||
- `GET {hub}/api/roles/obsigate`, en-tête **Cookie complet** de la requête
|
||||
relayé tel quel, `Accept: application/json`, délai **2 s** ;
|
||||
- réponse 200 : `{"app", "sub", "role", "explicite", "ttl_secondes": 60}`,
|
||||
`role` ∈ {`aucun`, `utilisateur`, `administrateur`} ;
|
||||
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de la
|
||||
barre commune (#223) — `DRACO_HUB_URL` explicite d'abord, dérivation
|
||||
par l'hôte sœur (suffixe d'environnement de l'hôte de l'agent)
|
||||
ensuite, défaut officiel enfin. Jamais d'URL dev figée.
|
||||
|
||||
## Client — `backend/hub_roles.py`
|
||||
|
||||
Sur le patron du pilote (`src/hub_roles.rs` de ShaarIt) :
|
||||
|
||||
- cache mémoire par `sub`, TTL **60 s** ;
|
||||
- horodatage posé **avant** l'appel : un échec conserve l'ancien cache
|
||||
et ne déclenche pas de rafale vers un Hub en panne ;
|
||||
- toute erreur (réseau, délai, statut ≠ 200, JSON illisible, rôle hors
|
||||
contrat) → rôle **inconnu** (`None`) → l'appelant garde son
|
||||
comportement actuel. Le registre ne crée jamais un verrouillage
|
||||
nouveau.
|
||||
|
||||
## Effets (sessions établies par draco seulement)
|
||||
|
||||
1. **Niveau connu `aucun`** → `user_for_draco` refuse : pas de session
|
||||
établie par draco. `require_auth` répond alors **403** avec un
|
||||
message explicite en français (niveau « aucun », matrice des rôles
|
||||
du Hub, accès natif toujours possible), consigné via
|
||||
`request.state.role_hub_refuse`. L'**authentification native**
|
||||
d'ObsiGate (son propre login — c'est l'émettrice, son accès propre
|
||||
est le secours) n'est **jamais** touchée.
|
||||
2. **Routes d'administration `/api/auth/admin/*`** (CRUD des comptes,
|
||||
`backend/auth/router.py`) : nouvelle garde `require_admin_hub`
|
||||
(`backend/auth/middleware.py`) = `require_admin` + , quand la
|
||||
requête est authentifiée par le **canal draco** (`_draco_sub`
|
||||
présent, `_hub_role` joint par `user_for_draco`) et que le rôle est
|
||||
**connu**, exigence du niveau `administrateur` du registre.
|
||||
Sessions natives → règles actuelles inchangées ; rôle inconnu →
|
||||
règles actuelles inchangées. Le registre n'élève aucun privilège :
|
||||
un compte local non admin reste refusé par la règle locale, quel
|
||||
que soit son niveau au Hub.
|
||||
3. **Inchangés** : validation locale du jeton, émission du cookie,
|
||||
révocations (`jti`), renouvellement glissant. **Aucun rôle dans le
|
||||
jeton** (claims minimaux de #216 préservés).
|
||||
|
||||
Périmètre assumé : la garde du registre vise les routes nommées par la
|
||||
carte, `/api/auth/admin/*`. Les autres surfaces gardées par
|
||||
`require_admin` (configuration, vaults, coffres…) gardent leurs règles
|
||||
actuelles ; les y soumettre serait un changement d'une ligne par
|
||||
surface (`require_admin_hub` est réutilisable tel quel) si Bruno le
|
||||
décide.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_hub_roles.py` : parsing du contrat (URL, Cookie relayé,
|
||||
`Accept`, délai 2 s), rôles reconnus / hors contrat, JSON illisible,
|
||||
erreurs HTTP et réseau → inconnu ; résolution de l'URL du Hub
|
||||
(explicite, dérivée `-dev`, défaut officiel) ; cache par `sub`, TTL,
|
||||
échec sans rafale avec ancien cache conservé ; intégration sur le
|
||||
chemin draco (refus « aucun » explicite, login natif intact sous
|
||||
« aucun », rôle inconnu = comportement actuel, `utilisateur` Hub
|
||||
refusé sur `/api/auth/admin/*`, `administrateur` Hub accepté, compte
|
||||
local non admin non élevé, session native jamais bloquée par le Hub).
|
||||
@@ -0,0 +1,85 @@
|
||||
# #227 — HUB-22 : API machine des sessions draco (liste et révocation)
|
||||
|
||||
**Carte :** vague C du plan Hub v2.1 (HUB-22) — le Hub consomme ces
|
||||
routes en 0.17.0 pour l'écran des sessions d'un utilisateur.
|
||||
**Version :** 2.85.0. **Prérequis :** HUB-13 (#226) — registre des
|
||||
émissions `jti` et garde machine.
|
||||
|
||||
## Objet
|
||||
|
||||
Le Hub doit lister les sessions actives d'un utilisateur et les
|
||||
révoquer une à une ou toutes, via le canal `jti` livré en HUB-13.
|
||||
Aucune route machine « sessions » n'existait : cette livraison la crée,
|
||||
sous la même garde (`require_machine_admin`, clé
|
||||
`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` /
|
||||
`X-Draco-Actor`) et avec la même journalisation d'audit que les routes
|
||||
comptes existantes. Sans la clé configurée, l'API reste inerte (404) ;
|
||||
sans clé valide, 401.
|
||||
|
||||
## Routes — `/api/admin/machine/comptes/{username}/sessions`
|
||||
|
||||
| Méthode | Chemin | Effet |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/machine/comptes/{username}/sessions` | Sessions actives du compte |
|
||||
| POST | `/api/admin/machine/comptes/{username}/sessions/{jti}/revoquer` | Révocation d'une session |
|
||||
| POST | `/api/admin/machine/comptes/{username}/sessions/revoquer-toutes` | Révocation de toutes les sessions |
|
||||
|
||||
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
|
||||
configurée), 404 `jti` absent de l'ensemble actif du compte.
|
||||
|
||||
### Liste — `GET …/sessions`
|
||||
|
||||
`200 {"username": …, "sessions": [{"jti", "exp", "expire_le",
|
||||
"revoquee"}]}` — une entrée par `jti` émis et non expiré, triée par
|
||||
`exp` croissant ; `expire_le` est l'échéance en ISO 8601 UTC et
|
||||
`revoquee` signale un `jti` déjà présent dans la liste des
|
||||
révocations.
|
||||
|
||||
### Révocation unitaire — `POST …/sessions/{jti}/revoquer`
|
||||
|
||||
`200 {"jti", "revoquee": true}`. Le `jti` n'est révoqué que s'il
|
||||
appartient à l'ensemble émis du compte visé et n'est pas expiré :
|
||||
**404 sinon, jamais de révocation cross-utilisateur**.
|
||||
|
||||
### Tout révoquer — `POST …/sessions/revoquer-toutes`
|
||||
|
||||
`200 {"revoquees": n}` — réutilise `revoke_all_for_user` de HUB-13.
|
||||
C'est la « déconnexion globale », **distincte de la désactivation** :
|
||||
le compte reste actif, une nouvelle connexion émet une nouvelle
|
||||
session.
|
||||
|
||||
## Backend — `backend/draco_session.py`
|
||||
|
||||
- `issued_sessions_for_user(sub)` : sessions actives
|
||||
(`exp > maintenant`) du `sub`, `[{jti, exp, revoquee}]` triées par
|
||||
`exp` croissant ; les entrées expirées du `sub` sont purgées du
|
||||
registre au passage.
|
||||
- `revoke_one_for_user(sub, jti)` : révoque via le `revoke()`
|
||||
existant uniquement si le `jti` appartient à l'ensemble émis de ce
|
||||
`sub` et n'est pas expiré ; retourne un booléen.
|
||||
|
||||
La clé du registre est le même `sub` que celui de HUB-13
|
||||
(`_identifiant_sessions` dans `comptes_machine.py` : `id` du compte,
|
||||
à défaut `username`) — factorisée depuis `_revoquer_sessions`, qui
|
||||
l'utilise aussi désormais.
|
||||
|
||||
## Journalisation
|
||||
|
||||
Les deux écritures sont consignées dans `data/audit.log` via
|
||||
`log_compte_admin` : actions `session_revoquee` (détail : le `jti`) et
|
||||
`sessions_revoquees` (détail : nombre de `jti` révoqués), acteur =
|
||||
identité de service `X-Draco-Actor`. La lecture suit le patron des
|
||||
lectures existantes (non journalisée).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_draco_session.py` : liste triée et marquage `revoquee`,
|
||||
purge des expirés, révocation unitaire, refus cross-utilisateur,
|
||||
refus d'un `jti` expiré ; le registre des émissions est isolé en
|
||||
fichier temporaire dans la fixture.
|
||||
- `tests/test_comptes_machine.py` (`TestSessions`) : liste (contenu,
|
||||
`expire_le` ISO 8601 UTC), liste vide, compte inconnu 404,
|
||||
révocation unitaire (succès + effet sur `verify_token`),
|
||||
cross-utilisateur 404 sans effet, tout-révoquer (compte toujours
|
||||
actif, autres comptes intacts, seconde passe à 0), garde (401 sans
|
||||
clé, 404 API non configurée), journalisation.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Retour SSO — cookie draco réémis avant le retour (BUG-125)
|
||||
|
||||
Suite du chantier « entrée SSO de bout en bout » : retest réel de
|
||||
Bruno le 2026-11-08, après la fusion de BUG-123/BUG-124 (2.86.2)
|
||||
et du correctif « l'hôte de la requête fait foi » côté applications
|
||||
sœurs (Kanbante 0.46.9). Le retour tombe désormais sur le bon
|
||||
hôte, mais l'utilisateur arrive devant le formulaire de
|
||||
l'application sœur, **sans message d'erreur** — pas connecté.
|
||||
|
||||
## Diagnostic (pile « conteneur dev réel », navigateur réel)
|
||||
|
||||
Les formes d'échec du pont Kanbante ont d'abord été
|
||||
cartographiées une à une (jetons forgés/révoqués, compte lié
|
||||
désactivé, rôle « aucun » au registre du Hub) :
|
||||
|
||||
| Cause au pont | Arrivée |
|
||||
|---|---|
|
||||
| Jeton valide, correspondance saine | session ouverte |
|
||||
| Échec de résolution (compte lié désactivé) | `/login?draco=echec` **avec bandeau** |
|
||||
| Rôle « aucun » au registre | page **403 explicite** |
|
||||
| Jeton absent, illisible (signature) ou **révoqué** | `/login` **nu, sans signal** |
|
||||
|
||||
Le formulaire nu de Bruno ne peut donc venir que de la dernière
|
||||
ligne : au moment du pont, aucun jeton draco valide n'est
|
||||
présenté. La variante qui le reproduit à l'identique :
|
||||
|
||||
1. login chez ObsiGate (session établie, `sessionStorage`
|
||||
alimenté) ;
|
||||
2. suppression des cookies `access_token` et `draco_session`
|
||||
(le cookie `refresh_token`, 7 j, est conservé) ;
|
||||
3. Kanbante → « Se connecter avec ObsiGate (SSO) » ;
|
||||
4. la SPA honore le `return_to` par son chemin court
|
||||
(`sessionStorage` encore « valide ») — **aucun échange
|
||||
serveur** ;
|
||||
5. la requête du pont part **sans cookie `draco_session`**
|
||||
(journalisée) → Kanbante répond `303 /login`, formulaire nu.
|
||||
|
||||
Un second clic, lui, aboutit : le premier passage chez ObsiGate
|
||||
a provoqué entre-temps la réémission du cookie (activité
|
||||
authentifiée traversant `DracoRenewalMiddleware`).
|
||||
|
||||
## Cause racine
|
||||
|
||||
La réémission du cookie `draco_session` est opportuniste :
|
||||
`DracoRenewalMiddleware` ne peut la poser que sur la réponse
|
||||
d'une requête portant un cookie `access_token` natif valide.
|
||||
Le chemin court d'`initAuth()` (session « valide » en
|
||||
`sessionStorage`, ajouté pour BUG-123) redirige vers
|
||||
l'application sœur **sans aucune requête authentifiée** : si
|
||||
les deux cookies ont disparu indépendamment de la session
|
||||
(cookies purgés séparément, profil restauré, domaines nettoyés
|
||||
à des rythmes différents — le cookie draco vit 12 h,
|
||||
l'`access_token` 1 h, le refresh 7 j et `sessionStorage`
|
||||
suit sa propre logique d'onglet), rien ne réémet le cookie
|
||||
avant le départ, et le pont sœur est muet par construction
|
||||
(un jeton absent est indiscernable d'un visiteur jamais
|
||||
connecté).
|
||||
|
||||
## Correctif (`frontend/js/auth.js`, `initAuth()`)
|
||||
|
||||
Quand la page est l'arrivée d'un `/sso/authorize`
|
||||
(`return_to` présent dans l'URL), le chemin court est
|
||||
écarté : `initAuth()` emprunte toujours le chemin du refresh
|
||||
silencieux — `POST /api/auth/refresh` (rotation, pose un
|
||||
nouvel `access_token`) puis `GET /api/auth/me`, dont la
|
||||
requête porte ce cookie et dont la réponse traverse
|
||||
`DracoRenewalMiddleware` : le cookie draco absent, invalide
|
||||
ou en fenêtre de renouvellement est réémis **avant**
|
||||
`_honorReturnTo()`. Si le refresh échoue (plus de session
|
||||
centrale réelle), l'écran de connexion s'affiche comme
|
||||
avant et le `return_to`, toujours dans l'URL, sera honoré
|
||||
par `saveToken` après login frais.
|
||||
|
||||
Hors arrivée `authorize`, le démarrage est strictement
|
||||
inchangé (chemin court conservé pour les visites directes
|
||||
d'ObsiGate) ; les refresh en cours de session ne redirigent
|
||||
toujours jamais.
|
||||
|
||||
## Garde et preuve
|
||||
|
||||
- `tests/test_draco_barre.py::test_retour_sso_refresh_force_bug_125`
|
||||
(test épinglé : le chemin court est conditionné à l'absence
|
||||
de `return_to`, et le compte d'appels `_honorReturnTo` de
|
||||
BUG-123 reste à 2).
|
||||
- Preuve navigateur dans la pile « conteneur dev réel »
|
||||
(dossier `preuve-retour-sso-hote`, scripts `diag-*.py`) :
|
||||
la variante fautive retourne **connectée** après correctif,
|
||||
la requête du pont portant le cookie draco réémis.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Ce correctif ferme le mécanisme « retour honoré sans cookie »
|
||||
côté émettrice. Les deux autres formes silencieuses relevées
|
||||
au diagnostic relèvent d'autres gestes, inchangés : jeton
|
||||
révoqué (déconnexion globale, révocation de session au Hub —
|
||||
guéri par une reconnexion ou une activité chez ObsiGate) et
|
||||
jeton illisible pour une application sœur (secret partagé
|
||||
propre à son conteneur — permanent tant que la configuration
|
||||
n'est pas alignée).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Vault DIR « Spark » — exclusions par vault et lecture seule déclarable
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre d'exposer dans ObsiGate des répertoires techniques (code du
|
||||
service d'agents, workspaces du compagnon, coquilles d'exécuteurs) sous un
|
||||
vault `DIR` monté en lecture seule — le vault « Spark » — sans jamais
|
||||
rendre visibles leurs zones sensibles (profils et clés des exécuteurs,
|
||||
`.home` des workspaces, `.venv`, etc.).
|
||||
|
||||
Avant ce chantier, deux manques le bloquaient proprement :
|
||||
|
||||
1. Les exclusions n'existaient qu'au niveau de l'indexeur (`IGNORED_DIRS`,
|
||||
global) : la navigation et la lecture montraient le disque tel quel.
|
||||
2. La lecture seule d'un vault n'était ni déclarable ni affichée : les
|
||||
menus contextuels proposaient renommer / déplacer / supprimer, qui
|
||||
échouaient ensuite côté serveur (montage `:ro`).
|
||||
|
||||
## Contrat de configuration (vaults DIR seulement)
|
||||
|
||||
Lues par `load_vault_config()` (`backend/indexer.py`, boucle `DIR_N`),
|
||||
rangées dans le dict `config` du vault ; les vaults `VAULT` ne sont pas
|
||||
touchés et les défauts (vide / faux) conservent le comportement actuel :
|
||||
|
||||
| Variable | Forme | Effet |
|
||||
|---|---|---|
|
||||
| `DIR_N_READONLY` | `true` / `false` | Mutations refusées en 403 `read_only` ; `readonly: true` exposé par `/api/vaults` ; l'UI masque les actions d'écriture |
|
||||
| `DIR_N_IGNORED_DIRS` | `.home,.venv` | Noms de dossiers cachés à toute profondeur, en plus du global |
|
||||
| `DIR_N_IGNORED_PATHS` | `Executeurs/*/data,Executeurs/*/.env` | Motifs fnmatch sur le chemin relatif POSIX ; un dossier qui matche cache tout son sous-arbre |
|
||||
|
||||
Exemple visé pour le vault Spark :
|
||||
|
||||
```
|
||||
DIR_1_NAME=Spark
|
||||
DIR_1_PATH=/vaults/Spark
|
||||
DIR_1_READONLY=true
|
||||
DIR_1_IGNORED_DIRS=.home,.venv,__pycache__,node_modules,.git
|
||||
DIR_1_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env,*/compagnon.json
|
||||
```
|
||||
|
||||
(le montage lui-même — volume `:ro`, arborescence assemblée sur l'hôte —
|
||||
relève de la configuration de déploiement, pas d'ObsiGate.)
|
||||
|
||||
## Implémentation
|
||||
|
||||
- **Helper central** : `chemin_exclu(vault_name, rel_path)` et
|
||||
`chemin_exclu_dans_config(config, rel_path)` dans
|
||||
`backend/services/vaults.py` — segment présent dans `ignoredDirs`, ou
|
||||
fnmatch du chemin ou d'un de ses préfixes contre `ignoredPaths`.
|
||||
Tolérant si vault / config absents. `vault_readonly(vault_name)` au
|
||||
même endroit.
|
||||
- **Navigation** : `browse_directory` filtre les items exclus (et leurs
|
||||
compteurs d'enfants) ; naviguer directement dans un chemin exclu
|
||||
répond 404. `list_all_files` et `list_paths` filtrent aussi.
|
||||
- **Lecture** : un chemin exclu est indiscernable d'un chemin inexistant
|
||||
(404, sans détail sur la règle) — `read_raw_file` / `read_file_text`
|
||||
(`backend/services/files.py`), route `GET /api/file/{vault}` et route
|
||||
de téléchargement (`backend/routers/files_read.py`).
|
||||
- **Indexation** : le scan (`_scan_vault`, qui reçoit le `vault_cfg`)
|
||||
élague les dossiers exclus et saute les fichiers exclus, en plus du
|
||||
global `IGNORED_DIRS` inchangé.
|
||||
- **Lecture seule** : `_ensure_writable(root, vault_name)` dans
|
||||
`backend/services/mutations.py` refuse d'abord sur la déclaration
|
||||
(`DIR_N_READONLY`), avant le garde filesystem existant — toutes les
|
||||
mutations du service (créer, éditer, renommer, déplacer, supprimer,
|
||||
upload, xlsx/csv) passent par ce point.
|
||||
- **API / UI** : `VaultInfo.readonly` (`backend/schemas.py`) rempli par
|
||||
`list_accessible_vaults` ; côté frontend, `vaultIsReadonly(nom)` dans
|
||||
`frontend/js/sidebar.js` alimente `ContextMenuManager`,
|
||||
`attachTreeItemActionButton` et `attachTreeItemLongPress` pour le
|
||||
vault et ses dossiers / fichiers (aucun autre fichier JS touché).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_spark_vault_dir.py` : parsing des trois variables, helper
|
||||
`chemin_exclu` (noms et motifs), navigation filtrée, lectures exclues en
|
||||
404 (vue fichier, raw, téléchargement), fichier non exclu lisible,
|
||||
indexation filtrée, mutation refusée en 403 sur vault readonly et
|
||||
`/api/vaults` exposant `readonly=true`.
|
||||
@@ -1214,6 +1214,30 @@
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem("obsigate-theme") || "dark";
|
||||
applyTheme(saved);
|
||||
// Thème commun de la suite (HUB-18, fusion #132) : la barre
|
||||
// commune (barre v0.5.0) applique la préférence du Hub
|
||||
// (sombre/clair/système) à l'ouverture — directement sur
|
||||
// data-theme, et via l'événement « draco:theme » pour que le
|
||||
// mécanisme propre d'ObsiGate suive (icône et libellé du bouton,
|
||||
// feuilles highlight.js). applyTheme mémorise le mode appliqué
|
||||
// dans « obsigate-theme » : la préférence du Hub devient le
|
||||
// choix local courant, et le bouton de bascule reste le maître
|
||||
// pour la suite de la session (aucune ré-application de la barre
|
||||
// après l'ouverture). « systeme » arrive déjà résolu en mode
|
||||
// effectif (detail.mode) par la barre. 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.
|
||||
document.addEventListener("draco:theme", (e) => {
|
||||
const mode = e && e.detail && e.detail.mode;
|
||||
if (mode === "dark" || mode === "light") applyTheme(mode);
|
||||
});
|
||||
// Si la barre s'est initialisée AVANT ce module (ordre des
|
||||
// scripts defer/module selon la page), sa préférence déjà
|
||||
// résolue depuis son cache est reprise ici.
|
||||
const barre = window.DRACO_BAR;
|
||||
if (barre && (barre.themeMode === "dark" || barre.themeMode === "light")) {
|
||||
applyTheme(barre.themeMode);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||
.draco-alertes-overlay {
|
||||
position: fixed; inset: 0; z-index: 30000;
|
||||
display: none; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||
}
|
||||
.draco-alertes-box {
|
||||
width: 720px; max-width: 100%;
|
||||
background: var(--bg-primary, #1f1f1f);
|
||||
color: var(--text, #eceee9);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||
.draco-alertes-close {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||
.draco-alertes-item {
|
||||
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||
padding: 9px 12px; margin: 6px 0;
|
||||
}
|
||||
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||
.draco-alertes-title:hover { text-decoration: underline; }
|
||||
.draco-alertes-detail {
|
||||
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||
.draco-alertes-empty a { color: inherit; }
|
||||
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||
.draco-alertes-tab {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||
}
|
||||
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||
.draco-alertes-filtre input {
|
||||
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||
.draco-alertes-compte:empty { display: none; }
|
||||
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||
@@ -0,0 +1,749 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
|
||||
* (onglets + composant partagé + onglet Console Spark).
|
||||
*
|
||||
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
|
||||
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
|
||||
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
|
||||
* place, toute évolution passe par commons puis re-vendoring.
|
||||
*
|
||||
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||
* interroge les API de notifications réellement exposées par chaque
|
||||
* application, avec les identifiants du navigateur (credentials:
|
||||
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||
* ou non authentifiée affiche son état réel par section (une section
|
||||
* en échec n'empêche jamais de consulter les autres), jamais du vide
|
||||
* déguisé. En attendant le correctif CORS des allowlists (autre
|
||||
* chantier), une section inter-origines peut afficher cet état
|
||||
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
|
||||
* normal — aucun état « Injoignable » systématique n'est présenté
|
||||
* comme normal.
|
||||
*
|
||||
* Sources réelles (audit #69, 2026-10-28, inchangé) :
|
||||
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||
* - TikTak : GET {base}/api/notifications
|
||||
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||
* base + "/notifications".
|
||||
* - Kanbante : GET {base}/api/events?limit=100
|
||||
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||
* masqués), pas un journal lisible par l'utilisateur. L'onglet
|
||||
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
|
||||
* sur agent-commun main au moment de ce chantier (autre chantier
|
||||
* en cours). L'onglet Console Spark affiche donc un état vide
|
||||
* HONNÊTE (« source indisponible », lien vers la console) et ne
|
||||
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
|
||||
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
|
||||
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
|
||||
* l'autre chantier ; forme attendue : tableau, ou
|
||||
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
|
||||
* detail|message|context, date|created_at, active (booléen) ou
|
||||
* status/state (pause budget, accès requis, en attente, fermée…),
|
||||
* url|link. Les missions en pause budget / accès requis et les
|
||||
* approbations en attente y apparaîtront avec leurs états réels.
|
||||
*
|
||||
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
|
||||
* « Console Spark » ajouté par le chantier 5 porte data-app
|
||||
* "agent", donc la base agent suit le même ordre) ;
|
||||
* 4. défaut officiel (production).
|
||||
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
|
||||
* incluse).
|
||||
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||
* que sur un hôte -dev, exactement comme la barre.
|
||||
*
|
||||
* Présentation (identique partout, imposée par ce seul fichier) :
|
||||
* champs par alerte = application, titre, date, état, lien profond
|
||||
* (environnement -dev respecté via la résolution ci-dessus) ;
|
||||
* ordre = date décroissante dans chaque onglet (sans date en
|
||||
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
|
||||
* si absente ou illisible ; libellés d'état affichés = « Active »,
|
||||
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
|
||||
* ou l'état réel console quand la source existe. Le type brut
|
||||
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
|
||||
* libellé d'état en anglais.
|
||||
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
|
||||
* par application ne montrent QUE les alertes actives ; Historique
|
||||
* ne montre QUE les alertes fermées (lues / événements Kanbante /
|
||||
* console fermées). Le filtre texte (titre + contexte) s'applique
|
||||
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
|
||||
* actifs et historique. L'onglet courant est dans l'URL
|
||||
* (#alertes=<onglet>, partageable, retour arrière via pushState).
|
||||
*
|
||||
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||
* pour la pastille (repli : mise à jour directe du badge).
|
||||
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||
* avec une valeur venue du serveur.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_URLS = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
agent: "https://agent.dracodev.net/"
|
||||
};
|
||||
var DEV_URLS = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
|
||||
};
|
||||
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
|
||||
var TAB_LABELS = {
|
||||
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante",
|
||||
console: "Console Spark", historique: "Historique"
|
||||
};
|
||||
var EVENT_LABELS = {
|
||||
created: "créée", moved: "déplacée", closed: "fermée",
|
||||
reopened: "rouverte", commented: "commentée"
|
||||
};
|
||||
var CONSOLE_ACTIVE_STATUSES = [
|
||||
"active", "pending", "pause", "pause_budget", "acces_requis",
|
||||
"en_attente", "en attente", "requise", "awaiting"
|
||||
];
|
||||
var CONSOLE_CLOSED_STATUSES = [
|
||||
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
|
||||
];
|
||||
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
function cleanUrl(u) {
|
||||
if (!u || typeof u !== "string") return null;
|
||||
if (u.indexOf("{{") !== -1) return null;
|
||||
if (!u.replace(/\s/g, "")) return null;
|
||||
return u;
|
||||
}
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||
}
|
||||
function joinUrl(base, path) {
|
||||
var b = withSlash(base);
|
||||
if (!path) return b;
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return b + String(path).replace(/^\//, "");
|
||||
}
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||
? String(s).replace(" ", "T") + "Z" : s);
|
||||
return isNaN(t.getTime()) ? null : t;
|
||||
}
|
||||
function fmtDate(s) {
|
||||
var t = parseDate(s);
|
||||
if (!t) return "";
|
||||
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||
}
|
||||
function capitalize(s) {
|
||||
s = String(s || "");
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
|
||||
function resolveUrls(env) {
|
||||
env = env || {};
|
||||
var hostname = env.hostname;
|
||||
if (hostname === undefined) {
|
||||
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||
}
|
||||
var dev = isDevHost(hostname);
|
||||
var urls = {};
|
||||
ALL_APPS.forEach(function (key) {
|
||||
var app = key === "console" ? "agent" : key;
|
||||
var out = key === "console" ? "agent" : key;
|
||||
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|
||||
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
if (!u) {
|
||||
var href = null;
|
||||
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
|
||||
else if (typeof document !== "undefined" && document.querySelector) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
href = tab ? tab.getAttribute("href") : null;
|
||||
}
|
||||
u = cleanUrl(href);
|
||||
}
|
||||
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
|
||||
if (!u) {
|
||||
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||
try {
|
||||
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[out] = withSlash(u);
|
||||
if (key === "console") urls.console = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
function browserEnv() {
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
var bar = global.DRACO_BAR || {};
|
||||
var tabs = {};
|
||||
try {
|
||||
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||
}
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
return {
|
||||
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
|
||||
consoleUrl: null
|
||||
};
|
||||
}
|
||||
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
|
||||
* n'est devinée : sans configuration, la source est indisponible
|
||||
* (état vide honnête, voir renderCurrent). */
|
||||
function consoleSourceUrl(env) {
|
||||
env = env || {};
|
||||
if (env.consoleUrl) return env.consoleUrl;
|
||||
try {
|
||||
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
|
||||
} catch (e) { /* rien */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFlowdeck(base, payload) {
|
||||
var list = (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.message || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeTiktak(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.body || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.kind || "",
|
||||
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeKanbante(base, payload) {
|
||||
var list = (payload && payload.events) || [];
|
||||
return list.map(function (e) {
|
||||
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||
return {
|
||||
app: "kanbante", id: e.id,
|
||||
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||
detail: detail, date: e.created_at || "",
|
||||
active: false, state: label, kind: e.event_type || "",
|
||||
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeConsole(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload
|
||||
: (payload && (payload.alerts || payload.items)) || [];
|
||||
return list.map(function (n) {
|
||||
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
|
||||
var stateRaw = String(n.state || status || "");
|
||||
var active;
|
||||
if (typeof n.active === "boolean") active = n.active;
|
||||
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
|
||||
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
|
||||
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
|
||||
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
|
||||
else active = true; /* source réelle, statut inconnu : on l'expose en active */
|
||||
return {
|
||||
app: "console", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
|
||||
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
|
||||
kind: n.kind || n.type || "",
|
||||
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
|
||||
* normalize* sont inchangées, pour compatibilité des tests #69). */
|
||||
function displayState(a) {
|
||||
if (a.active) return "Active";
|
||||
if (a.app === "kanbante") return capitalize(a.state);
|
||||
if (a.app === "console") return capitalize(a.state);
|
||||
return "Lue";
|
||||
}
|
||||
function metaLine(a) {
|
||||
var date = fmtDate(a.date) || "sans date";
|
||||
/* Kanbante : le type brut (« moved ») dupliquait le libellé
|
||||
* d'état ; il n'est plus affiché. Les autres apps gardent leur
|
||||
* type quand il apporte une information. */
|
||||
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
|
||||
var kind = a.kind ? " · " + a.kind : "";
|
||||
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier).
|
||||
* Dans un onglet, le périmètre est déjà homogène (que des actives
|
||||
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
|
||||
function sortAlerts(list) {
|
||||
return list.slice().sort(function (a, b) {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
var da = parseDate(a.date), db = parseDate(b.date);
|
||||
if (da && db) return db - da;
|
||||
if (da) return -1;
|
||||
if (db) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
function countActive(list) {
|
||||
var n = 0;
|
||||
list.forEach(function (a) { if (a.active) n++; });
|
||||
return n;
|
||||
}
|
||||
function allItems(results) {
|
||||
var all = [];
|
||||
ALL_APPS.forEach(function (app) {
|
||||
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
return all;
|
||||
}
|
||||
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
|
||||
* Tout et onglets par application = actives seulement ; Historique =
|
||||
* fermées seulement. Les erreurs de section sont remontées dans
|
||||
* chaque onglet concerné (jamais silencieusement avalées). */
|
||||
function scopeAlerts(all, tab, results) {
|
||||
results = results || {};
|
||||
var items = [], errors = [];
|
||||
function collect(app, pred) {
|
||||
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
|
||||
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
|
||||
}
|
||||
if (tab === "historique") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
|
||||
} else if (tab === "tout") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
} else if (ALL_APPS.indexOf(tab) !== -1) {
|
||||
collect(tab, function (a) { return a.active; });
|
||||
} else {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
}
|
||||
return {
|
||||
items: sortAlerts(items), errors: errors,
|
||||
consoleMissing: !!(results.console && results.console.sourceMissing)
|
||||
};
|
||||
}
|
||||
function countByTab(all, results) {
|
||||
var counts = {};
|
||||
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
|
||||
return counts;
|
||||
}
|
||||
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
|
||||
* contexte, insensible à la casse, sans jamais mélanger actifs et
|
||||
* historique (le périmètre est déjà fixé par scopeAlerts). */
|
||||
function filterAlerts(list, q) {
|
||||
q = String(q || "").trim().toLowerCase();
|
||||
if (!q) return list.slice();
|
||||
return list.filter(function (a) {
|
||||
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|
||||
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
}
|
||||
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
|
||||
* "#alertes" -> "tout", autre -> null. */
|
||||
function parseAlertTab(hash) {
|
||||
if (!hash) return null;
|
||||
var h = String(hash).replace(/^#/, "");
|
||||
if (h === "alertes") return "tout";
|
||||
var m = /^alertes=([a-z]+)$/.exec(h);
|
||||
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
|
||||
return null;
|
||||
}
|
||||
function hashForTab(tab) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
return "#alertes=" + tab;
|
||||
}
|
||||
|
||||
/* ---- Rendu (navigateur seulement) ---- */
|
||||
var panel = null, listEl = null, tabsEl = null, filterEl = null,
|
||||
countEl = null, badgeTimer = null;
|
||||
var currentTab = "tout", currentData = null, currentFilter = "";
|
||||
var panelPushed = false, closingViaBack = false, suppressHash = false;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var d = document.createElement(tag);
|
||||
if (cls) d.className = cls;
|
||||
if (text !== undefined && text !== null) d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
function ensurePanel() {
|
||||
if (panel) return panel;
|
||||
var ov = el("div", "draco-alertes-overlay");
|
||||
ov.setAttribute("role", "dialog");
|
||||
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||
var box = el("div", "draco-alertes-box");
|
||||
var head = el("div", "draco-alertes-head");
|
||||
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||
var close = el("button", "draco-alertes-close", "✕");
|
||||
close.setAttribute("aria-label", "Fermer");
|
||||
close.addEventListener("click", function () { closePanel(); });
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
box.appendChild(el("p", "draco-alertes-note",
|
||||
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||
"les alertes sont relues dans chaque application à l'ouverture. " +
|
||||
"Une section en échec n'empêche pas de consulter les autres."));
|
||||
tabsEl = el("div", "draco-alertes-tabs");
|
||||
tabsEl.setAttribute("role", "tablist");
|
||||
tabsEl.setAttribute("aria-label", "Alertes par application");
|
||||
tabsEl.addEventListener("keydown", onTabsKeydown);
|
||||
box.appendChild(tabsEl);
|
||||
var filtre = el("div", "draco-alertes-filtre");
|
||||
var lbl = el("label", "", "Filtrer");
|
||||
lbl.setAttribute("for", "draco-alertes-filtre-input");
|
||||
filtre.appendChild(lbl);
|
||||
filterEl = el("input", "");
|
||||
filterEl.type = "search";
|
||||
filterEl.id = "draco-alertes-filtre-input";
|
||||
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
|
||||
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
|
||||
filterEl.addEventListener("input", function () {
|
||||
currentFilter = filterEl.value;
|
||||
renderCurrent();
|
||||
});
|
||||
filtre.appendChild(filterEl);
|
||||
box.appendChild(filtre);
|
||||
countEl = el("p", "draco-alertes-compte", "");
|
||||
box.appendChild(countEl);
|
||||
listEl = el("div", "draco-alertes-list");
|
||||
listEl.setAttribute("role", "tabpanel");
|
||||
box.appendChild(listEl);
|
||||
ov.appendChild(box);
|
||||
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
panel = ov;
|
||||
return ov;
|
||||
}
|
||||
function closePanel() {
|
||||
if (!panel) return;
|
||||
panel.style.display = "none";
|
||||
if (panelPushed) {
|
||||
panelPushed = false;
|
||||
closingViaBack = true;
|
||||
try { history.back(); } catch (e) { closingViaBack = false; }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var h = parseAlertTab(window.location.hash);
|
||||
if (h) {
|
||||
suppressHash = true;
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
suppressHash = false;
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function onHashChange() {
|
||||
if (suppressHash) return;
|
||||
if (closingViaBack) { closingViaBack = false; return; }
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) {
|
||||
if (!panel || panel.style.display === "none") openPanel(tab);
|
||||
else setTab(tab, false);
|
||||
} else if (panel && panel.style.display !== "none") {
|
||||
panelPushed = false;
|
||||
panel.style.display = "none";
|
||||
}
|
||||
}
|
||||
function onTabsKeydown(ev) {
|
||||
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
|
||||
if (keys.indexOf(ev.key) === -1) return;
|
||||
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
|
||||
var idx = TABS.indexOf(currentTab);
|
||||
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
|
||||
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
|
||||
else if (ev.key === "Home") idx = 0;
|
||||
else if (ev.key === "End") idx = TABS.length - 1;
|
||||
ev.preventDefault();
|
||||
setTab(TABS[idx], true);
|
||||
if (btns[idx] && btns[idx].focus) btns[idx].focus();
|
||||
}
|
||||
function renderTabs() {
|
||||
if (!tabsEl || !currentData) return;
|
||||
tabsEl.textContent = "";
|
||||
var all = allItems(currentData.results);
|
||||
var counts = countByTab(all, currentData.results);
|
||||
TABS.forEach(function (tab) {
|
||||
var b = el("button", "draco-alertes-tab",
|
||||
TAB_LABELS[tab] + " (" + counts[tab] + ")");
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "tab");
|
||||
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
|
||||
b.tabIndex = tab === currentTab ? 0 : -1;
|
||||
b.addEventListener("click", function () { setTab(tab, true); });
|
||||
tabsEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
function renderRow(a) {
|
||||
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||
var main = el("div", "draco-alertes-main");
|
||||
var link = el("a", "draco-alertes-title", a.title);
|
||||
link.href = a.url;
|
||||
main.appendChild(link);
|
||||
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderErrorRow(app, msg) {
|
||||
var row = el("div", "draco-alertes-item is-error");
|
||||
var main = el("div", "draco-alertes-main");
|
||||
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
|
||||
main.appendChild(el("div", "draco-alertes-detail", msg));
|
||||
main.appendChild(el("div", "draco-alertes-meta",
|
||||
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderCurrent() {
|
||||
if (!listEl || !currentData) return;
|
||||
renderTabs();
|
||||
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
|
||||
var filtered = filterAlerts(scope.items, currentFilter);
|
||||
var q = String(currentFilter || "").trim();
|
||||
if (countEl) {
|
||||
countEl.textContent = q
|
||||
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
|
||||
: "";
|
||||
}
|
||||
listEl.textContent = "";
|
||||
/* Onglet Console sans source : état vide honnête, jamais d'alertes
|
||||
* inventées, avec le lien profond vers la console. */
|
||||
if (currentTab === "console" && scope.consoleMissing) {
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", "Console Spark (0)"));
|
||||
sec.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte console — source indisponible : l'endpoint " +
|
||||
"d'alertes console (agent-commun, autre chantier) n'est pas " +
|
||||
"encore publié. Les missions en pause budget / accès requis " +
|
||||
"et les approbations en attente apparaîtront ici, avec leurs " +
|
||||
"états réels, dès sa livraison."));
|
||||
var link = el("div", "draco-alertes-empty");
|
||||
var a = el("a", "", "Ouvrir la Console Spark");
|
||||
a.href = currentData.urls.agent || currentData.urls.console || "#";
|
||||
link.appendChild(a);
|
||||
sec.appendChild(link);
|
||||
listEl.appendChild(sec);
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
return;
|
||||
}
|
||||
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
|
||||
var secOg = el("section", "draco-alertes-section");
|
||||
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||
secOg.appendChild(el("p", "draco-alertes-empty",
|
||||
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||
var linkOg = el("div", "draco-alertes-empty");
|
||||
var aOg = el("a", "", "Ouvrir ObsiGate");
|
||||
aOg.href = currentData.urls.obsigate;
|
||||
linkOg.appendChild(aOg);
|
||||
secOg.appendChild(linkOg);
|
||||
listEl.appendChild(secOg);
|
||||
return;
|
||||
}
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
if (!scope.items.length && !scope.errors.length) {
|
||||
var empty = currentTab === "historique"
|
||||
? "Aucune alerte fermée dans les applications joignables."
|
||||
: currentTab === "tout"
|
||||
? "Aucune alerte active dans les applications joignables."
|
||||
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
|
||||
if (currentTab === "console") empty = "Aucune alerte console.";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", empty));
|
||||
return;
|
||||
}
|
||||
if (q && !filtered.length) {
|
||||
listEl.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte dans cet onglet pour ce filtre."));
|
||||
return;
|
||||
}
|
||||
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
|
||||
}
|
||||
function setTab(tab, push) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
currentTab = tab;
|
||||
renderCurrent();
|
||||
if (push) {
|
||||
try {
|
||||
history.pushState(null, "", hashForTab(tab));
|
||||
panelPushed = true;
|
||||
} catch (e) { /* URL partageable : repli silencieux */ }
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson(url, timeoutMs) {
|
||||
var ctrl = null, timer = null;
|
||||
try {
|
||||
if (typeof AbortController !== "undefined") {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||
}
|
||||
} catch (e) { ctrl = null; }
|
||||
var opts = { credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
return fetch(url, opts).then(function (r) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!r.ok) {
|
||||
var err = new Error("HTTP " + r.status);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return r.json();
|
||||
}).catch(function (e) {
|
||||
if (timer) clearTimeout(timer);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
function loadAll() {
|
||||
var urls = resolveUrls(browserEnv());
|
||||
var consoleUrl = consoleSourceUrl(browserEnv());
|
||||
var jobs = {
|
||||
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||
};
|
||||
if (consoleUrl) {
|
||||
var agentBase = urls.agent || urls.console;
|
||||
jobs.console = fetchJson(consoleUrl)
|
||||
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
|
||||
}
|
||||
var keys = Object.keys(jobs);
|
||||
return Promise.all(keys.map(function (k) {
|
||||
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||
var msg = (e && e.status === 401)
|
||||
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||
return [k, { items: [], error: msg }];
|
||||
});
|
||||
})).then(function (pairs) {
|
||||
var results = { obsigate: { items: [] } };
|
||||
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
|
||||
return { urls: urls, results: results };
|
||||
});
|
||||
}
|
||||
function setBadge(n) {
|
||||
try {
|
||||
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||
global.DRACO_BAR.setCounts({ alertes: n });
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
try {
|
||||
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||
b.textContent = n;
|
||||
b.hidden = !(n > 0);
|
||||
});
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function refreshBadge() {
|
||||
return loadAll().then(function (r) {
|
||||
var n = countActive(allItems(r.results));
|
||||
setBadge(n);
|
||||
return n;
|
||||
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||
}
|
||||
function openPanel(initialTab) {
|
||||
ensurePanel();
|
||||
panel.style.display = "flex";
|
||||
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
|
||||
currentTab = tab;
|
||||
currentFilter = "";
|
||||
if (filterEl) filterEl.value = "";
|
||||
listEl.textContent = "";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||
if (tabsEl) tabsEl.textContent = "";
|
||||
loadAll().then(function (r) {
|
||||
currentData = r;
|
||||
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
|
||||
* l'URL pour être partageable ; ouverture par lien partagé : le
|
||||
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
|
||||
var fromHash = parseAlertTab(window.location.hash);
|
||||
if (!fromHash) {
|
||||
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
|
||||
}
|
||||
renderCurrent();
|
||||
setBadge(countActive(allItems(r.results)));
|
||||
}).catch(function () {
|
||||
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
|
||||
renderCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
var api = {
|
||||
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||
_test: {
|
||||
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
|
||||
sortAlerts: sortAlerts, countActive: countActive,
|
||||
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
|
||||
countByTab: countByTab, parseAlertTab: parseAlertTab,
|
||||
hashForTab: hashForTab, displayState: displayState,
|
||||
metaLine: metaLine, fmtDate: fmtDate,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
|
||||
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
|
||||
}
|
||||
};
|
||||
global.DRACO_ALERTES = api;
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||
|
||||
/* Pastille au chargement (après les scripts defer de la barre) ;
|
||||
* un lien partagé #alertes=<onglet> ouvre directement le panneau
|
||||
* sur cet onglet. */
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
badgeTimer = setTimeout(refreshBadge, 300);
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) setTimeout(function () { openPanel(tab); }, 350);
|
||||
});
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -2,15 +2,44 @@
|
||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
|
||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
|
||||
menu des applications) ; l'avatar est un vrai <button>.
|
||||
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
|
||||
défaut supprimés ; le menu de la marque gagne « Console Spark ».
|
||||
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
|
||||
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
|
||||
Alertes icône seule.
|
||||
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
|
||||
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
|
||||
v0.2.0 : nouvel onglet « ShaarIt » (data-app="shaarit", --app émeraude
|
||||
#059669, logo logos/shaarit.svg via {{LOGO_SHAARIT}}) avant l'onglet
|
||||
agent ; GARDE data-theme : les blocs prefers-color-scheme ne
|
||||
s'appliquent que si <html> n'a AUCUN attribut data-theme
|
||||
(html:not([data-theme])) — avant ce correctif, un hôte ayant choisi
|
||||
le clair via data-theme="light" héritait quand même du sombre
|
||||
système (piège constaté dans les copies vendangées de
|
||||
Kanbante/Flowdeck ; les re-vendanges de la v0.2.0 le corrigent).
|
||||
v0.3.0 (HUB-1) : aucun style nouveau — les liens du Hub du menu
|
||||
compte (Télécharger, À Propos, Aides, Profil) réutilisent
|
||||
.draco-menu-item (déjà ancre-compatible : text-decoration none)
|
||||
et le séparateur .draco-menu-sep du menu des applications.
|
||||
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
|
||||
items du menu compte (SVG inline construit par le JS) : 18 px,
|
||||
replacées à gauche du libellé par order: -1 (l'item est en
|
||||
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
|
||||
Jetons repris du prototype validé Console v7.2.
|
||||
v0.4.1 (fusion #125) : menu compte resserré — min-width
|
||||
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
|
||||
le menu était trop large une fois les icônes visibles).
|
||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien
|
||||
(aucun attribut data-theme sur <html>). */
|
||||
|
||||
.draco-bar {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
--agent: #c00000; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
@@ -18,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;
|
||||
@@ -32,7 +65,16 @@
|
||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { line-height: 0; }
|
||||
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
|
||||
fond transparent — noire sur barre claire (filtre), blanche sur
|
||||
barre foncée (filtre annulé par les règles thème ci-dessous). */
|
||||
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) .draco-bar .draco-brand-mark img { filter: none; }
|
||||
}
|
||||
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
@@ -42,6 +84,43 @@
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
|
||||
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
|
||||
* droite ; ses items réutilisent .draco-apps-item. */
|
||||
.draco-compte-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
|
||||
min-width: 232px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-compte-menu[hidden] { display: none; }
|
||||
.draco-menu-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||
background: transparent; color: var(--ink); font: inherit; text-align: left;
|
||||
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;
|
||||
border-bottom: 1px solid var(--line); margin-bottom: 6px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.draco-apps-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
@@ -66,6 +145,13 @@
|
||||
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||
}
|
||||
/* Icônes logos (2026-10-29, corrigé le même jour) : l'image du logo
|
||||
* fourni, telle quelle, remplit la tuile 30 px — jamais de glyphe
|
||||
* texte, jamais de redessin. Le fond de la tuile est transparent :
|
||||
* les logos PNG portent déjà leur fond (dégradé), le SVG TikTak
|
||||
* reste sur le fond de la barre. */
|
||||
.draco-apptab-ico { background: transparent; }
|
||||
.draco-apptab-ico img { width: 30px; height: 30px; display: block; border-radius: 9px; object-fit: cover; }
|
||||
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
|
||||
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
|
||||
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
|
||||
@@ -103,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;
|
||||
@@ -122,8 +213,10 @@
|
||||
}
|
||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||
.draco-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: #2f6fed; color: #fff; font-weight: 700;
|
||||
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||
display: grid; place-items: center; flex: none;
|
||||
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||
@@ -139,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; }
|
||||
@@ -147,8 +240,8 @@
|
||||
@media (max-width: 900px) {
|
||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
||||
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||
avatar. Le rail, la recherche et les domaines disparaissent :
|
||||
chaque app garde ses contrôles natifs (barre
|
||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||
@@ -162,5 +255,12 @@
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||
/* v0.2.0 : sur ShaarIt elle-même (data-current="shaarit"), c'est
|
||||
l'onglet ShaarIt qui reste visible en mobile. */
|
||||
.draco-bar[data-current="shaarit"] .draco-apptab[data-app="shaarit"],
|
||||
/* Chantier 5 : sur la console elle-même (data-current="agent",
|
||||
programme console #71), c'est l'onglet Console Spark qui reste
|
||||
visible en mobile — les 4 hôtes gardent leur propre courant. */
|
||||
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||
* v0.1.3 : menu des applications ouvert par la marque ✦).
|
||||
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
|
||||
* v0.1.5 : noms publics par configuration — aucun nom public d'un
|
||||
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
|
||||
* correctif fusion #70 (SANS bump de version, publication séparée) :
|
||||
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
|
||||
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
|
||||
* « Menu des applications », « Focus » et « Écouteurs globaux ») ;
|
||||
* v0.2.0 : 5e application ShaarIt — clé urls.shaarit, défaut officiel
|
||||
* https://shaarit.dracodev.net/, dérivation -dev shaarit-dev (même
|
||||
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
|
||||
* (data-app="shaarit", après Kanbante, avant agent) est repris
|
||||
* automatiquement par la construction du menu de la marque.
|
||||
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
|
||||
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
|
||||
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
|
||||
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
|
||||
* sœurs. Le menu compte gagne trois vrais liens (patron
|
||||
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
|
||||
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
|
||||
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
|
||||
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
|
||||
* celui du Hub). « Déconnexion » reste l'action relayée
|
||||
* (data-compte-act="deconnexion" / onAction), inchangée.
|
||||
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
|
||||
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
|
||||
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
|
||||
* sans étape « href rendu » (voir « URL » ci-dessous).
|
||||
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
|
||||
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
|
||||
* reçoit un glyphe SVG inline dans le style des icônes du rail de
|
||||
* la console (contour, trait fin régulier, currentColor, aucun
|
||||
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
|
||||
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
|
||||
* comportement : liens résolus et Déconnexion relayée inchangés.
|
||||
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
|
||||
* la barre apprend la préférence de thème du profil du Hub
|
||||
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
|
||||
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
|
||||
* local court et repli intégral sur le comportement actuel de
|
||||
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
|
||||
* 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é.
|
||||
* v0.7.0 (chantier « entrée SSO de bout en bout », 2026-11-08) :
|
||||
* l'API publique gagne DRACO_BAR.setUser(user) — l'hôte pousse le
|
||||
* nom du compte courant DÈS qu'il l'établit, notamment une SPA qui
|
||||
* termine son login sans rechargement (cas ObsiGate : le nom
|
||||
* n'atteignait la barre qu'au chargement de la page suivante, le
|
||||
* menu compte affichait l'en-tête générique « Compte » jusqu'au
|
||||
* rechargement forcé). Même contrat de valeur que cfg.user
|
||||
* (chaîne, ou objet display_name / name / username). L'avatar
|
||||
* (initiale, infobulle) et l'en-tête du menu compte sont mis à
|
||||
* jour immédiatement, sans rechargement ; null rétablit l'état
|
||||
* neutre (✦, « Compte »).
|
||||
*
|
||||
* 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
|
||||
@@ -10,19 +79,358 @@
|
||||
* current: "tiktak", // app courante
|
||||
* domain: "pro", // domaine actif
|
||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||
* // ou { display_name: "Bruno", username: "bruno" }
|
||||
* admin: true, // (v0.6.0, optionnel)
|
||||
* // administrateur de suite ÉTABLI par l'hôte : démasque
|
||||
* // l'entrée « Administration » du menu compte. Absent ou
|
||||
* // non-strictement-true → entrée masquée. Peut aussi être
|
||||
* // posé après coup via DRACO_BAR.setAdmin(true).
|
||||
* urls: { // bases des applications
|
||||
* obsigate: "https://og.dracodev.net/",
|
||||
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||
* tiktak: "https://tiktak.dracodev.net/",
|
||||
* kanbante: "https://kanbante.dracodev.net/",
|
||||
* shaarit: "https://shaarit.dracodev.net/",
|
||||
* agent: "https://agent.dracodev.net/",
|
||||
* spark: "https://spark.dracodev.net/",
|
||||
* hub: "https://hub.dracodev.net/"
|
||||
* },
|
||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||
* } // qu'elle ne connaît pas
|
||||
*
|
||||
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
|
||||
* résolution, dans l'ordre STRICT, pour chaque app :
|
||||
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
|
||||
* serveur ou client) depuis SA configuration d'environnement —
|
||||
* explicite, donc prioritaire, même sur un hôte -dev ;
|
||||
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
|
||||
* (première étiquette se terminant par -dev : tiktak-dev,
|
||||
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
|
||||
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
|
||||
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
|
||||
* les noms SANS -dev faute de configuration explicite ;
|
||||
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
|
||||
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
|
||||
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
|
||||
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
|
||||
* partout (config injectée comme href rendu) et ne peut pas être exposé.
|
||||
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
||||
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
|
||||
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
|
||||
* La clé spark ne correspond à aucun onglet de la barre : elle est
|
||||
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
|
||||
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
|
||||
* elle, a un onglet depuis le chantier 5 (ordre, v0.2.0 : ObsiGate,
|
||||
* Flowdeck, TikTak, Kanbante, ShaarIt, Console Spark) : elle suit
|
||||
* donc l'étape « href
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls. Même résolution pour shaarit (v0.2.0) :
|
||||
* explicite > dérivation -dev shaarit-dev > href rendu
|
||||
* ({{URL_SHAARIT}}) > défaut officiel.
|
||||
* Cas hub (v0.3.0) : PAS d'onglet, donc pas d'étape « href rendu » —
|
||||
* explicite (DRACO_BAR_CONFIG.urls.hub, alimenté par le jeton
|
||||
* {{URL_HUB}} remplacé côté serveur par l'hôte) > dérivation -dev
|
||||
* hub-dev sur hôte -dev > défaut officiel
|
||||
* https://hub.dracodev.net/. La base résolue sert les quatre liens
|
||||
* du menu compte (telecharger, a-propos, aide, profil).
|
||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||
* ouvre le menu des applications, construit depuis les onglets, plus
|
||||
* (depuis le correctif #70) une entrée « Console Spark » vers la
|
||||
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
|
||||
* c'est le moyen de changer d'app en mobile, où les onglets sont
|
||||
* repliés (v0.1.2). Menu compte : voir ci-dessous.
|
||||
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
|
||||
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
|
||||
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
|
||||
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
|
||||
* retirées des gabarits hôtes — le menu natif d'ObsiGate
|
||||
* (#header-menu-btn) lui-même n'est pas touché.
|
||||
*
|
||||
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
|
||||
* son menu par défaut (console agent + applications) sont supprimés
|
||||
* du fragment, du JS et du CSS. La console agent reste joignable par
|
||||
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
|
||||
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
|
||||
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
|
||||
* onglets présents dans la barre, puis une entrée « Console Spark »
|
||||
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
||||
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
|
||||
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
|
||||
* 4) défaut officiel prod https://agent.dracodev.net/.
|
||||
* AUCUN hôte spark-dev n'est inventé :
|
||||
* la clé spark, elle, suit toujours son propre contrat (explicite >
|
||||
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
||||
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
||||
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
|
||||
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
|
||||
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
|
||||
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
|
||||
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
|
||||
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
|
||||
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
|
||||
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
|
||||
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
|
||||
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
|
||||
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
|
||||
* addGlobalListener), PAS via document.addEventListener. Cause
|
||||
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
|
||||
* remplace document/window.addEventListener par un registre
|
||||
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
|
||||
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
|
||||
* compris ceux de draco-barre.js (script defer, enregistré après le
|
||||
* tcInit() initial). La barre survit à la navigation (hors .layout
|
||||
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
|
||||
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
|
||||
* la marque, lien href="/", est interceptée par l'intercepteur SPA
|
||||
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
|
||||
* une navigation interne au lieu de basculer le menu. Les écouteurs
|
||||
* posés sur le prototype ne figurent pas dans le registre et ne sont
|
||||
* donc jamais supprimés par le teardown ; en complément, le gabarit
|
||||
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
|
||||
* la marque ne déclenche plus de navigation douce. Les autres hôtes
|
||||
* (SSR, rechargement complet) ne sont pas concernés par le teardown
|
||||
* mais bénéficient du même code ; repli : si EventTarget.prototype
|
||||
* est indisponible (mini-DOM de tests), document.addEventListener
|
||||
* classique est utilisé.
|
||||
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
|
||||
* un « B » codé en dur dans le fragment, est un vrai <button>
|
||||
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
|
||||
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
|
||||
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
|
||||
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
|
||||
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
|
||||
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
|
||||
* 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";
|
||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||
|
||||
/* Défauts OFFICIELS (production) — les seuls noms publics présents
|
||||
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
|
||||
var DEFAULT_URLS = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
shaarit: "https://shaarit.dracodev.net/",
|
||||
agent: "https://agent.dracodev.net/",
|
||||
spark: "https://spark.dracodev.net/",
|
||||
hub: "https://hub.dracodev.net/"
|
||||
};
|
||||
|
||||
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
|
||||
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
|
||||
* volontairement absent : aucun hôte spark-dev connu, aucune URL
|
||||
* inventée ; spark retombe sur href rendu puis défaut prod. */
|
||||
var DEV_URLS = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
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", shaarit: "ShaarIt"
|
||||
};
|
||||
|
||||
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
|
||||
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
|
||||
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
|
||||
* ne déclenche rien). Insensible à la casse. */
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
|
||||
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
|
||||
function cleanUrl(u) {
|
||||
if (!u || typeof u !== "string") return null;
|
||||
if (u.indexOf("{{") !== -1) return null;
|
||||
if (!u.replace(/\s/g, "")) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||
}
|
||||
|
||||
function resolveUrls() {
|
||||
var host = "";
|
||||
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
|
||||
var dev = isDevHost(host);
|
||||
var urls = {};
|
||||
Object.keys(DEFAULT_URLS).forEach(function (app) {
|
||||
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
|
||||
var u = cleanUrl(cfg.urls && cfg.urls[app]);
|
||||
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
|
||||
if (!u) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var href = tab ? tab.getAttribute("href") : null;
|
||||
u = cleanUrl(href);
|
||||
}
|
||||
/* 4. Défaut officiel (production). */
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[app] = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
function applyUrls(urls) {
|
||||
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
|
||||
});
|
||||
}
|
||||
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. */
|
||||
@@ -43,26 +451,132 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
||||
* Construit depuis les onglets présents dans la barre : mêmes
|
||||
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
||||
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
||||
* choix d'une application (la navigation suit son cours). */
|
||||
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
|
||||
function userName() {
|
||||
var u = cfg.user;
|
||||
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
|
||||
if (u && typeof u === "object") {
|
||||
var n = u.display_name || u.name || u.username;
|
||||
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function titleName() {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
var t = av ? av.getAttribute("title") : null;
|
||||
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
|
||||
return null;
|
||||
}
|
||||
function displayName() { return userName() || titleName(); }
|
||||
function syncAvatar() {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
if (!av) return;
|
||||
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
|
||||
* sans data-act) : le rendre déclencheur du menu compte. */
|
||||
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
|
||||
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
|
||||
var name = displayName();
|
||||
if (name) {
|
||||
av.textContent = name.charAt(0).toUpperCase();
|
||||
av.setAttribute("title", name);
|
||||
av.setAttribute("aria-label", "Compte : " + name);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Menus déroulants (apps, compte) ───────────────────────────────
|
||||
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
|
||||
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||
var menu = null;
|
||||
var avatarBtn = document.querySelector(".draco-avatar");
|
||||
var appsMenu = null, compteMenu = null;
|
||||
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
|
||||
* lui rend le focus à la fermeture (correctif #70). */
|
||||
var lastOpener = null;
|
||||
|
||||
function buildMenu() {
|
||||
if (!bar || !brand || menu) return;
|
||||
menu = document.createElement("div");
|
||||
menu.className = "draco-apps-menu";
|
||||
menu.hidden = true;
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Applications");
|
||||
function setExpanded(btn, open) {
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
function refreshMenuHrefs(menu) {
|
||||
if (!menu) return;
|
||||
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;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
setExpanded(btn, open);
|
||||
}
|
||||
function closeAllPopups(returnFocus) {
|
||||
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
|
||||
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||
/* Échap seulement : le clic extérieur ne déplace pas le focus
|
||||
* (voir l'en-tête, « Focus »). */
|
||||
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
|
||||
}
|
||||
function tabLabel(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
|
||||
return fort ? fort.textContent : (APP_LABELS[app] || app);
|
||||
}
|
||||
function tabRole(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||
return petit ? petit.textContent : "";
|
||||
}
|
||||
/* Menu des applications (v0.1.3) — construit depuis les onglets
|
||||
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
|
||||
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
|
||||
* extérieur, Échap, ou le choix d'une application (la navigation
|
||||
* suit son cours). */
|
||||
function buildAppsMenu() {
|
||||
if (!bar || !brand || appsMenu) return;
|
||||
appsMenu = document.createElement("div");
|
||||
appsMenu.className = "draco-apps-menu";
|
||||
appsMenu.hidden = true;
|
||||
appsMenu.setAttribute("role", "menu");
|
||||
appsMenu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
|
||||
* repris ici — l'entrée dédiée ci-dessous le représente déjà
|
||||
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
|
||||
* listerait « Console Spark » deux fois. */
|
||||
if (t.dataset.app === "agent") return;
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = t.getAttribute("href");
|
||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||
a.dataset.app = t.dataset.app;
|
||||
a.setAttribute("role", "menuitem");
|
||||
var ico = t.querySelector(".draco-apptab-ico");
|
||||
@@ -84,44 +598,348 @@
|
||||
a.appendChild(txt);
|
||||
var badge = t.querySelector(".draco-badge");
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
menu.appendChild(a);
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
bar.appendChild(menu);
|
||||
/* Entrée « Console Spark » (#70) : après les onglets d'apps
|
||||
* (ObsiGate…ShaarIt, v0.2.0), vers la console
|
||||
* de l'agent commun (URL agent résolue — explicite > -dev >
|
||||
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
|
||||
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
|
||||
* autres items ; son icône CLONE celle de l'onglet agent
|
||||
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
|
||||
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
|
||||
* autres éléments de la barre n'est pas changé ici. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
appsMenu.appendChild(sep);
|
||||
var sparkItem = document.createElement("a");
|
||||
sparkItem.className = "draco-apps-item";
|
||||
sparkItem.href = resolvedUrls.agent || "#";
|
||||
sparkItem.dataset.app = "agent";
|
||||
sparkItem.setAttribute("role", "menuitem");
|
||||
var sparkIco;
|
||||
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
|
||||
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
|
||||
if (agentIco) {
|
||||
sparkIco = agentIco.cloneNode(true);
|
||||
} else {
|
||||
sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#d21f2c");
|
||||
sparkIco.setAttribute("data-icon", "agent");
|
||||
sparkIco.textContent = "✦";
|
||||
}
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
var sparkNom = document.createElement("span");
|
||||
sparkNom.className = "draco-apps-item-nom";
|
||||
sparkNom.textContent = "Console Spark";
|
||||
sparkTxt.appendChild(sparkNom);
|
||||
var sparkRole = document.createElement("span");
|
||||
sparkRole.className = "draco-apps-item-role";
|
||||
sparkRole.textContent = "Agent commun";
|
||||
sparkTxt.appendChild(sparkRole);
|
||||
sparkItem.appendChild(sparkTxt);
|
||||
appsMenu.appendChild(sparkItem);
|
||||
bar.appendChild(appsMenu);
|
||||
}
|
||||
function toggleAppsMenu(force) {
|
||||
buildAppsMenu();
|
||||
if (!appsMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||
togglePopup(appsMenu, brand, force);
|
||||
if (!appsMenu.hidden) lastOpener = brand;
|
||||
}
|
||||
|
||||
function toggleMenu(force) {
|
||||
buildMenu();
|
||||
if (!menu) return;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
/* 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;
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
toggleMenu();
|
||||
/* 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");
|
||||
compteMenu.className = "draco-compte-menu";
|
||||
compteMenu.hidden = true;
|
||||
compteMenu.setAttribute("role", "menu");
|
||||
compteMenu.setAttribute("aria-label", "Compte");
|
||||
var head = document.createElement("div");
|
||||
head.className = "draco-compte-nom";
|
||||
compteMenu.appendChild(head);
|
||||
HUB_LINKS.forEach(function (pair) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-menu-item";
|
||||
a.href = hubHref(pair[0]);
|
||||
a.setAttribute("data-hub-link", pair[0]);
|
||||
a.setAttribute("role", "menuitem");
|
||||
a.textContent = pair[1];
|
||||
a.appendChild(menuIcone(pair[0]));
|
||||
compteMenu.appendChild(a);
|
||||
});
|
||||
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() {
|
||||
if (!compteMenu) return;
|
||||
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();
|
||||
if (!compteMenu) return;
|
||||
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||
togglePopup(compteMenu, avatarBtn, force);
|
||||
if (!compteMenu.hidden) lastOpener = avatarBtn;
|
||||
}
|
||||
|
||||
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||
* événement draco:bar (contrat historique des actions relayées). */
|
||||
function relay(act, el, ev) {
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
}
|
||||
|
||||
/* Écouteurs globaux posés sur le prototype EventTarget quand il
|
||||
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
|
||||
* voir l'en-tête) ; sinon document.addEventListener classique. */
|
||||
function addGlobalListener(type, fn) {
|
||||
try {
|
||||
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
|
||||
EventTarget.prototype.addEventListener) {
|
||||
EventTarget.prototype.addEventListener.call(document, type, fn, false);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
document.addEventListener(type, fn);
|
||||
}
|
||||
|
||||
addGlobalListener("click", function (ev) {
|
||||
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||
if (compteItem) {
|
||||
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||
toggleCompteMenu(false);
|
||||
return;
|
||||
}
|
||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
||||
toggleMenu(false);
|
||||
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
toggleAppsMenu();
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "compte") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
toggleCompteMenu();
|
||||
return;
|
||||
}
|
||||
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||
* sur son déclencheur déjà traité ci-dessus). */
|
||||
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||
ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups(false);
|
||||
}
|
||||
if (!el) return;
|
||||
var act = el.dataset.act;
|
||||
if (act === "domaine") setDomain(el.dataset.dom);
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
relay(act, el, ev);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") toggleMenu(false);
|
||||
addGlobalListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") { closeAllPopups(true); return; }
|
||||
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
|
||||
* ouverture explicite + focus sur le premier item du menu (#70).
|
||||
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
|
||||
* ci-dessus (pas de double bascule ici). */
|
||||
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
|
||||
var brandEl = ev.target.closest('[data-act="apps"]');
|
||||
if (brandEl) {
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
toggleAppsMenu();
|
||||
if (appsMenu && !appsMenu.hidden) {
|
||||
var first = appsMenu.querySelector(".draco-apps-item");
|
||||
if (first && typeof first.focus === "function") first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||
* setUrls(urls) fusionne de nouvelles bases (même contrat que
|
||||
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
|
||||
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
|
||||
* les bases résolues (défauts inclus), pour le panneau Spark et tout
|
||||
* autre consommateur de l'hôte. */
|
||||
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;
|
||||
},
|
||||
/* Identité (v0.7.0) : l'hôte pousse le nom du compte courant
|
||||
* dès qu'il l'établit — même contrat de valeur que cfg.user
|
||||
* (chaîne, ou objet display_name / name / username). L'avatar
|
||||
* et l'en-tête du menu compte sont resynchronisés aussitôt,
|
||||
* sans rechargement. setUser(null) rétablit l'état neutre. */
|
||||
setUser: function (u) {
|
||||
cfg.user = u || null;
|
||||
if (!u) {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
if (av) {
|
||||
av.textContent = "✦";
|
||||
av.removeAttribute("title");
|
||||
av.setAttribute("aria-label", "Compte");
|
||||
}
|
||||
}
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
}
|
||||
};
|
||||
|
||||
buildMenu();
|
||||
applyUrls(resolvedUrls);
|
||||
buildAppsMenu();
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
initThemeCommun();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -37,16 +37,19 @@
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-panneau-titres { min-width: 0; flex: 1; }
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.draco-panneau-hd .soustitre {
|
||||
font-size: 12px; color: var(--p-doux); display: flex; align-items: center; gap: 6px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-panneau-hd .soustitre .point { width: 8px; height: 8px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||
.draco-panneau-hd .soustitre.ok .point { background: #2ea36b; }
|
||||
.draco-panneau-hd .soustitre.err .point { background: #cf4b32; }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; flex: none;
|
||||
}
|
||||
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||
/* Barre des discussions (façon Muse) */
|
||||
.draco-disc-bar {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
@@ -112,18 +115,87 @@
|
||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||
.draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
|
||||
/* Puces de suggestions, comme la console : au-dessus du composer. */
|
||||
.draco-puces {
|
||||
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
.draco-puces button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-puces button:hover { background: var(--p-chip-survol); }
|
||||
/* ── Composer façon console (pilule) ─────────────────────────
|
||||
* +, textarea auto-extensible, micro ou envoi rond bleu ↑ ;
|
||||
* pendant la dictée, la rangée d'enregistrement remplace la saisie. */
|
||||
.draco-composer {
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
|
||||
background: var(--p-surface); border: 1px solid var(--p-bord);
|
||||
border-radius: 24px; padding: 8px 10px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / .05);
|
||||
}
|
||||
.draco-composer [hidden] { display: none !important; }
|
||||
.draco-composer-ligne { display: flex; align-items: flex-end; gap: 6px; }
|
||||
.draco-composer textarea {
|
||||
flex: 1; border: 0; background: none; resize: none; outline: none;
|
||||
padding: 8px 2px; max-height: 152px; overflow-y: auto; line-height: 1.45;
|
||||
min-height: calc(1.45em + 16px); font: inherit; color: var(--p-texte);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.draco-composer textarea::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
.draco-composer .composer-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
|
||||
background: var(--p-chip); color: var(--p-texte); font-size: 17px;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
|
||||
}
|
||||
.draco-composer .composer-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-composer .composer-btn.danger { color: var(--p-danger); }
|
||||
.draco-composer .composer-envoi {
|
||||
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
|
||||
background: #2f6fed; color: #fff; font-size: 17px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
|
||||
}
|
||||
.draco-composer .composer-envoi:disabled { opacity: .5; }
|
||||
.draco-composer .composer-btn .ic, .draco-composer .composer-envoi .ic { width: 19px; height: 19px; }
|
||||
.draco-composer-pieces { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 2px 0; }
|
||||
.draco-composer-pieces .piece-carte {
|
||||
position: relative; display: flex; align-items: center; gap: 8px;
|
||||
background: var(--p-chip); border: 1px solid var(--p-chip-bord);
|
||||
border-radius: 12px; padding: 5px 9px 5px 5px; max-width: 230px;
|
||||
}
|
||||
.draco-composer-pieces .piece-carte img { width: 42px; height: 42px; object-fit: cover; border-radius: 8px; display: block; }
|
||||
.draco-composer-pieces .piece-nom { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.draco-composer-pieces .piece-doc-ext {
|
||||
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--p-chip); border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
}
|
||||
.draco-composer-pieces .piece-ext { font-weight: 700; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
|
||||
.draco-composer-pieces .piece-retirer {
|
||||
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
|
||||
border-radius: 50%; border: 0; background: var(--p-texte); color: var(--p-fond);
|
||||
font-size: 10px; line-height: 1; padding: 0; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.draco-composer-enreg { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
|
||||
.draco-composer-enreg .enreg-temps { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 36px; }
|
||||
.draco-composer-enreg .enreg-texte {
|
||||
flex: 1; min-width: 0; color: var(--p-doux); font-size: 13px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-composer-enreg .enreg-onde { display: flex; align-items: center; gap: 3px; height: 26px; flex: none; }
|
||||
.draco-composer-enreg .enreg-onde span { width: 4px; height: 30%; border-radius: 2px; background: #2f6fed; }
|
||||
.draco-composer.en-cours .enreg-onde span { animation: onde 1.05s ease-in-out infinite; }
|
||||
.draco-composer-enreg .enreg-onde span:nth-child(3n+2) { animation-delay: .14s; }
|
||||
.draco-composer-enreg .enreg-onde span:nth-child(3n) { animation-delay: .28s; }
|
||||
@keyframes onde { 0%, 100% { height: 22%; } 50% { height: 96%; } }
|
||||
/* Pièces jointes dans une bulle utilisateur */
|
||||
.draco-msg-pieces { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
|
||||
.draco-msg-pieces img { max-width: 150px; max-height: 120px; border-radius: 10px; display: block; }
|
||||
.draco-msg-pieces .piece-fichier {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: var(--p-chip); border: 1px solid var(--p-chip-bord); color: var(--p-texte);
|
||||
border-radius: 9px; padding: 6px 9px; font-size: 12px;
|
||||
}
|
||||
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
var convKind = null; // 'principale' | 'parallele'
|
||||
var convTitre = "";
|
||||
var etatConnu = false;
|
||||
var domaineCharge = null; // domaine des discussions affichées
|
||||
var consoleUrlCharge = null; // URL (disc+domaine+app) de l'iframe console
|
||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||
var microEl = null, piecesEl = null, fichierEl = null;
|
||||
var titreEl = null, tiroirEl = null;
|
||||
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||
|
||||
@@ -68,11 +71,16 @@
|
||||
return correspondance[h] || null;
|
||||
}
|
||||
function urlConsole() {
|
||||
/* Le domaine de l'hôte est passé explicitement : la console
|
||||
* vit sur une autre origine, son localStorage ne voit jamais
|
||||
* celui de l'app hôte — sans ce paramètre, elle rouvrait
|
||||
* toujours le domaine de sa propre origine, pas celui du site. */
|
||||
var params = [];
|
||||
var id = convId || convStockee();
|
||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||
var app = appHote();
|
||||
if (app) params.push("app=" + encodeURIComponent(app));
|
||||
params.push("domain=" + encodeURIComponent(domaine()));
|
||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||
}
|
||||
|
||||
@@ -147,7 +155,15 @@
|
||||
|
||||
function ouvrirConsole() {
|
||||
assurerSurimpression();
|
||||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||
/* L'iframe n'est rechargée que si sa cible a changé
|
||||
* (discussion, domaine ou app) : même cible = état conservé,
|
||||
* cible différente = la console s'ajuste, au lieu de garder
|
||||
* la discussion du premier chargement. */
|
||||
var url = urlConsole();
|
||||
if (!surCadre.getAttribute("src") || url !== consoleUrlCharge) {
|
||||
surCadre.src = url;
|
||||
consoleUrlCharge = url;
|
||||
}
|
||||
if (surOuverte) return;
|
||||
surOuverte = true;
|
||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||
@@ -175,7 +191,12 @@
|
||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
}
|
||||
function modeSidebarConsole() { retrecirConsole(); }
|
||||
function modeSidebarConsole() {
|
||||
retrecirConsole();
|
||||
/* Retour au panneau : si le domaine a changé pendant que la
|
||||
* console occupait tout l'écran, le panneau s'ajuste aussi. */
|
||||
resynchroniserDomaine();
|
||||
}
|
||||
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||
|
||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||
@@ -246,13 +267,10 @@
|
||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||
'<div class="draco-panneau-hd">' +
|
||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||
' <div><div class="titre">Spark — agent commun</div>' +
|
||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
|
||||
' <div class="soustitre"><span class="point"></span><span class="txt">Connexion au service…</span></div></div>' +
|
||||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||
'</div>' +
|
||||
'<div class="draco-disc-bar">' +
|
||||
' <span class="d-titre">Discussion principale</span>' +
|
||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||
@@ -266,17 +284,40 @@
|
||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-msgs"></div>' +
|
||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||
' <button type="submit">Envoyer</button></form>' +
|
||||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||
'<div class="draco-panneau-ft">' +
|
||||
' <div class="draco-puces">' +
|
||||
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
|
||||
' <button type="button" data-sugg="projets">Où en sont mes projets ?</button>' +
|
||||
' <button type="button" data-sugg="chercher">Chercher partout…</button>' +
|
||||
' </div>' +
|
||||
' <form class="draco-panneau-form draco-composer">' +
|
||||
' <div class="draco-composer-pieces" hidden></div>' +
|
||||
' <div class="draco-composer-ligne">' +
|
||||
' <button type="button" class="composer-btn" data-comp="plus" title="Joindre une image ou un document" aria-label="Joindre une image ou un document"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>' +
|
||||
' <textarea rows="1" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark"></textarea>' +
|
||||
' <input type="file" accept="image/*,.pdf,.docx,.odt,.txt,.md,.html,.csv" multiple hidden>' +
|
||||
' <button type="button" class="composer-btn" data-comp="micro" title="Dictée vocale" aria-label="Dictée vocale" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg></button>' +
|
||||
' <button type="submit" class="composer-envoi" title="Envoyer" aria-label="Envoyer" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
|
||||
' </div>' +
|
||||
' <div class="draco-composer-enreg" hidden>' +
|
||||
' <button type="button" class="composer-btn danger" data-comp="enreg-annuler" title="Annuler la dictée" aria-label="Annuler la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>' +
|
||||
' <span class="enreg-temps">0:00</span>' +
|
||||
' <div class="enreg-onde" aria-hidden="true"></div>' +
|
||||
' <span class="enreg-texte"></span>' +
|
||||
' <button type="button" class="composer-btn" data-comp="enreg-stop" title="Arrêter et corriger la transcription" aria-label="Arrêter la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/></svg></button>' +
|
||||
' <button type="button" class="composer-envoi" data-comp="enreg-envoyer" title="Arrêter et envoyer" aria-label="Arrêter et envoyer"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
|
||||
' </div>' +
|
||||
' </form>' +
|
||||
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(root);
|
||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||
etatEl = root.querySelector(".draco-panneau-etat");
|
||||
inputEl = root.querySelector("input");
|
||||
sendBtn = root.querySelector("button[type=submit]");
|
||||
etatEl = root.querySelector(".draco-panneau-hd .soustitre");
|
||||
inputEl = root.querySelector(".draco-composer textarea");
|
||||
sendBtn = root.querySelector(".draco-composer button[type=submit]");
|
||||
microEl = root.querySelector('[data-comp="micro"]');
|
||||
piecesEl = root.querySelector(".draco-composer-pieces");
|
||||
fichierEl = root.querySelector(".draco-composer input[type=file]");
|
||||
titreEl = root.querySelector(".d-titre");
|
||||
tiroirEl = root.querySelector(".draco-discussions");
|
||||
versionEl = root.querySelector(".draco-panneau-version");
|
||||
@@ -288,6 +329,42 @@
|
||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||
root.querySelector("form").addEventListener("submit", envoyer);
|
||||
inputEl.addEventListener("input", majComposer);
|
||||
inputEl.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); envoyerComposer(); }
|
||||
});
|
||||
root.querySelector('[data-comp="plus"]').addEventListener("click", function () { fichierEl.click(); });
|
||||
fichierEl.addEventListener("change", function (ev) {
|
||||
ajouterFichiers(ev.target.files);
|
||||
ev.target.value = "";
|
||||
});
|
||||
microEl.addEventListener("click", demarrerDictee);
|
||||
root.querySelector('[data-comp="enreg-stop"]').addEventListener("click", function () { arreterDictee("stop"); });
|
||||
root.querySelector('[data-comp="enreg-annuler"]').addEventListener("click", function () { arreterDictee("annuler"); });
|
||||
root.querySelector('[data-comp="enreg-envoyer"]').addEventListener("click", function () { arreterDictee("envoyer"); });
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-sugg]"), function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
var mode = b.getAttribute("data-sugg");
|
||||
if (mode === "resume") {
|
||||
envoyerMessage("Résume ma journée : mes tâches du jour, les cartes en Révision et l'activité récente.", []);
|
||||
} else if (mode === "projets") {
|
||||
envoyerMessage("Où en sont mes projets ? Fais le point sur l'état des projets et du board.", []);
|
||||
} else {
|
||||
inputEl.value = "Cherche « » partout : ";
|
||||
majComposer();
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(9, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
/* Domaine changé dans un autre onglet pendant que le panneau
|
||||
* existe : l'événement storage ne part que vers les autres
|
||||
* documents, donc ici il arrive bien. Le changement dans le
|
||||
* même onglet est rattrapé à chaque ouverture (ouvrir()). */
|
||||
window.addEventListener("storage", function (ev) {
|
||||
if (ev.key === "draco_domain") resynchroniserDomaine();
|
||||
});
|
||||
majComposer();
|
||||
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||
appliquerTheme();
|
||||
surveillerTheme();
|
||||
@@ -326,8 +403,13 @@
|
||||
}
|
||||
|
||||
function majEtat(cls, texte) {
|
||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||
etatEl.querySelector(".txt").textContent = texte;
|
||||
/* Statut intégré au titre (sous-titre de l'en-tête) : plus de
|
||||
* barre d'état séparée. Le point vert/rouge/gris reste lisible
|
||||
* sans prendre une rangée entière. */
|
||||
if (!etatEl) return;
|
||||
etatEl.className = "soustitre " + cls;
|
||||
var t = etatEl.querySelector(".txt");
|
||||
if (t) t.textContent = texte;
|
||||
}
|
||||
function majTitre() {
|
||||
titreEl.textContent = convTitre || "Discussion principale";
|
||||
@@ -400,9 +482,21 @@
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
|
||||
/* Recharge la discussion du domaine courant quand il a changé
|
||||
* depuis le dernier chargement (ouverture du panneau, autre
|
||||
* onglet, retour du mode console). Sans changement : rien —
|
||||
* on ne casse jamais une conversation en cours pour rien. */
|
||||
function resynchroniserDomaine() {
|
||||
if (!root || domaine() === domaineCharge) return;
|
||||
msgsEl.innerHTML = "";
|
||||
tiroirEl.hidden = true;
|
||||
demarrerDiscussions();
|
||||
}
|
||||
|
||||
function demarrerDiscussions() {
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
domaineCharge = domaine();
|
||||
var precedente = convStockee();
|
||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||
@@ -413,6 +507,7 @@
|
||||
chargerHistorique();
|
||||
})
|
||||
.catch(function () {
|
||||
domaineCharge = null; // échec : la prochaine ouverture réessaiera
|
||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||
});
|
||||
}
|
||||
@@ -780,19 +875,232 @@
|
||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||
}
|
||||
|
||||
function envoyer(ev) {
|
||||
ev.preventDefault();
|
||||
var texte = inputEl.value.trim();
|
||||
if (!texte) return;
|
||||
/* ── Composer façon console ────────────────────────────────
|
||||
* Mêmes règles que la console : + (images/documents, 4 max),
|
||||
* textarea auto-extensible, micro affiché seulement sans texte
|
||||
* ni pièce et si le navigateur le supporte, envoi rond bleu
|
||||
* affiché seulement avec texte ou pièce. */
|
||||
var piecesEnAttente = [];
|
||||
var TYPES_IMAGES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
var TYPES_DOCUMENTS = ["application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"text/plain", "text/markdown", "text/html", "text/csv"];
|
||||
var TAILLE_MAX_IMAGE = 5 * 1024 * 1024;
|
||||
var TAILLE_MAX_DOC = 10 * 1024 * 1024;
|
||||
|
||||
function majComposer() {
|
||||
if (!inputEl) return;
|
||||
var aTexte = !!(inputEl.value && inputEl.value.trim());
|
||||
sendBtn.hidden = !(aTexte || piecesEnAttente.length);
|
||||
if (microEl) microEl.hidden = !dicteeSupportee || aTexte || piecesEnAttente.length;
|
||||
inputEl.style.height = "auto";
|
||||
var sh = inputEl.scrollHeight;
|
||||
inputEl.style.height = sh ? Math.min(sh, 152) + "px" : "";
|
||||
}
|
||||
|
||||
function rendrePiecesEnAttente() {
|
||||
piecesEl.innerHTML = "";
|
||||
piecesEl.hidden = !piecesEnAttente.length;
|
||||
piecesEnAttente.forEach(function (p, i) {
|
||||
var carte = document.createElement("div");
|
||||
carte.className = "piece-carte";
|
||||
var apercu;
|
||||
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
|
||||
apercu = document.createElement("img");
|
||||
apercu.src = p.dataUrl; apercu.alt = p.nom;
|
||||
} else {
|
||||
apercu = document.createElement("span");
|
||||
apercu.className = "piece-doc-ext";
|
||||
var ext = document.createElement("span");
|
||||
ext.className = "piece-ext";
|
||||
ext.textContent = (p.nom || "").split(".").pop() || "doc";
|
||||
apercu.appendChild(ext);
|
||||
}
|
||||
var nom = document.createElement("span");
|
||||
nom.className = "piece-nom"; nom.textContent = p.nom; nom.title = p.nom;
|
||||
var x = document.createElement("button");
|
||||
x.type = "button"; x.className = "piece-retirer"; x.textContent = "✕";
|
||||
x.title = "Retirer " + p.nom;
|
||||
x.setAttribute("aria-label", "Retirer " + p.nom);
|
||||
x.addEventListener("click", function () {
|
||||
piecesEnAttente.splice(i, 1);
|
||||
rendrePiecesEnAttente(); majComposer();
|
||||
});
|
||||
carte.appendChild(apercu); carte.appendChild(nom); carte.appendChild(x);
|
||||
piecesEl.appendChild(carte);
|
||||
});
|
||||
}
|
||||
|
||||
function ajouterFichiers(fichiers) {
|
||||
Array.prototype.forEach.call(fichiers, function (f) {
|
||||
if (piecesEnAttente.length >= 4) {
|
||||
ajouterMsg("sys", "Au plus 4 pièces par message.");
|
||||
return;
|
||||
}
|
||||
var estImage = TYPES_IMAGES.indexOf(f.type) >= 0;
|
||||
var estDoc = TYPES_DOCUMENTS.indexOf(f.type) >= 0;
|
||||
if (!estImage && !estDoc) {
|
||||
ajouterMsg("sys", "« " + f.name + " » refusée : images PNG, JPEG, GIF ou WebP, ou documents PDF, DOCX, ODT, TXT, MD, HTML ou CSV seulement.");
|
||||
return;
|
||||
}
|
||||
if (estImage && f.size > TAILLE_MAX_IMAGE) {
|
||||
ajouterMsg("sys", "« " + f.name + " » refusée : 5 Mo maximum par image.");
|
||||
return;
|
||||
}
|
||||
if (estDoc && f.size > TAILLE_MAX_DOC) {
|
||||
ajouterMsg("sys", "« " + f.name + " » refusée : 10 Mo maximum par document.");
|
||||
return;
|
||||
}
|
||||
var lecteur = new FileReader();
|
||||
lecteur.onload = function () {
|
||||
var dataUrl = String(lecteur.result || "");
|
||||
piecesEnAttente.push({
|
||||
nom: f.name, type: f.type,
|
||||
base64: dataUrl.split(",")[1] || "", dataUrl: dataUrl
|
||||
});
|
||||
rendrePiecesEnAttente(); majComposer();
|
||||
};
|
||||
lecteur.readAsDataURL(f);
|
||||
});
|
||||
}
|
||||
|
||||
function envoyerComposer() {
|
||||
var t = inputEl.value;
|
||||
var pieces = piecesEnAttente;
|
||||
piecesEnAttente = [];
|
||||
inputEl.value = "";
|
||||
ajouterMsg("user", texte);
|
||||
rendrePiecesEnAttente();
|
||||
majComposer();
|
||||
envoyerMessage(t, pieces);
|
||||
}
|
||||
|
||||
/* ── Dictée vocale — Web Speech API, 100 % navigateur, comme
|
||||
* la console (fr-CA, résultats intérimaires en direct). */
|
||||
var RecoVoc = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
var dicteeSupportee = !!RecoVoc;
|
||||
var reco = null, enregEnCours = false, enregDebut = 0, enregMinuteur = null;
|
||||
var texteAvantDictee = "", transcriptionFinale = "";
|
||||
var modeFinDictee = "stop", dicteeErreur = null;
|
||||
|
||||
function demarrerDictee() {
|
||||
if (!dicteeSupportee || enregEnCours) return;
|
||||
texteAvantDictee = inputEl.value;
|
||||
transcriptionFinale = "";
|
||||
modeFinDictee = "stop";
|
||||
dicteeErreur = null;
|
||||
try { reco = new RecoVoc(); } catch (e) { reco = null; return; }
|
||||
reco.lang = "fr-CA";
|
||||
reco.interimResults = true;
|
||||
reco.continuous = true;
|
||||
reco.onresult = function (ev) {
|
||||
var interim = "";
|
||||
for (var i = ev.resultIndex; i < ev.results.length; i++) {
|
||||
var r = ev.results[i];
|
||||
if (r.isFinal) transcriptionFinale += r[0].transcript;
|
||||
else interim += r[0].transcript;
|
||||
}
|
||||
var affiche = (texteAvantDictee + " " + transcriptionFinale + interim).trim();
|
||||
root.querySelector(".enreg-texte").textContent = affiche;
|
||||
inputEl.value = affiche;
|
||||
};
|
||||
reco.onerror = function (ev) {
|
||||
if (ev.error === "aborted") return;
|
||||
dicteeErreur = ev.error || "erreur";
|
||||
modeFinDictee = "annuler";
|
||||
};
|
||||
reco.onend = terminerDictee;
|
||||
try { reco.start(); } catch (e) { reco = null; return; }
|
||||
enregEnCours = true;
|
||||
enregDebut = Date.now();
|
||||
root.querySelector(".enreg-temps").textContent = "0:00";
|
||||
root.querySelector(".enreg-texte").textContent = "";
|
||||
var onde = root.querySelector(".enreg-onde");
|
||||
if (!onde.children.length) {
|
||||
for (var j = 0; j < 14; j++) onde.appendChild(document.createElement("span"));
|
||||
}
|
||||
root.querySelector(".draco-composer-ligne").hidden = true;
|
||||
root.querySelector(".draco-composer-enreg").hidden = false;
|
||||
root.querySelector(".draco-composer").classList.add("en-cours");
|
||||
enregMinuteur = window.setInterval(function () {
|
||||
var s = Math.floor((Date.now() - enregDebut) / 1000);
|
||||
root.querySelector(".enreg-temps").textContent = Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function arreterDictee(mode) {
|
||||
modeFinDictee = mode;
|
||||
if (reco) {
|
||||
try { reco.stop(); } catch (e) { terminerDictee(); }
|
||||
} else {
|
||||
terminerDictee();
|
||||
}
|
||||
}
|
||||
|
||||
function terminerDictee() {
|
||||
if (!enregEnCours) return;
|
||||
enregEnCours = false;
|
||||
if (enregMinuteur) { window.clearInterval(enregMinuteur); enregMinuteur = null; }
|
||||
reco = null;
|
||||
root.querySelector(".draco-composer-ligne").hidden = false;
|
||||
root.querySelector(".draco-composer-enreg").hidden = true;
|
||||
root.querySelector(".draco-composer").classList.remove("en-cours");
|
||||
if (modeFinDictee === "annuler") {
|
||||
inputEl.value = texteAvantDictee;
|
||||
if (dicteeErreur) {
|
||||
ajouterMsg("sys", "Dictée vocale interrompue (" + dicteeErreur + ") — micro refusé ou indisponible ?");
|
||||
dicteeErreur = null;
|
||||
}
|
||||
} else {
|
||||
inputEl.value = (texteAvantDictee + " " + transcriptionFinale).trim();
|
||||
}
|
||||
var mode = modeFinDictee;
|
||||
modeFinDictee = "stop";
|
||||
majComposer();
|
||||
if (mode === "envoyer") envoyerComposer();
|
||||
else inputEl.focus();
|
||||
}
|
||||
|
||||
function envoyer(ev) {
|
||||
if (ev) ev.preventDefault();
|
||||
envoyerComposer();
|
||||
}
|
||||
|
||||
function envoyerMessage(texte, pieces) {
|
||||
texte = (texte || "").trim();
|
||||
pieces = pieces || [];
|
||||
if (!texte && !pieces.length) return;
|
||||
var el = ajouterMsg("user", texte);
|
||||
if (pieces.length) {
|
||||
var zone = document.createElement("div");
|
||||
zone.className = "draco-msg-pieces";
|
||||
pieces.forEach(function (p) {
|
||||
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
|
||||
var img = document.createElement("img");
|
||||
img.src = p.dataUrl; img.alt = p.nom;
|
||||
zone.appendChild(img);
|
||||
} else {
|
||||
var past = document.createElement("span");
|
||||
past.className = "piece-fichier";
|
||||
past.textContent = p.nom;
|
||||
zone.appendChild(past);
|
||||
}
|
||||
});
|
||||
el.appendChild(zone);
|
||||
}
|
||||
sendBtn.disabled = true;
|
||||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||||
var corps = { message: texte, domain: domaine(), conversation_id: convId };
|
||||
if (pieces.length) {
|
||||
corps.pieces = pieces.map(function (p) {
|
||||
return { nom: p.nom, type: p.type, donnees_base64: p.base64 };
|
||||
});
|
||||
}
|
||||
fetch(API + "/api/v1/chat", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||||
body: JSON.stringify(corps)
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 401) {
|
||||
@@ -831,11 +1139,12 @@
|
||||
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
})
|
||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
||||
.finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
|
||||
}
|
||||
|
||||
function ouvrir() {
|
||||
if (!root) { construire(); demarrerDiscussions(); }
|
||||
else resynchroniserDomaine();
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
|
||||
<!-- ShaarIt — signet (bookmark) blanc sur fond émeraude #059669,
|
||||
couleur signature de l'app dans la barre (#059669, distincte du
|
||||
teal Kanbante #0d9488). Fond arrondi comme les logos PNG 180 px
|
||||
des autres apps ; géométrie simple, aucun redessin d'un logo
|
||||
existant : le signet EST le symbole de l'app (bookmarks). -->
|
||||
<rect x="8" y="8" width="224" height="224" rx="48" fill="#059669"/>
|
||||
<path d="M 82 52 L 158 52 L 158 190 L 120 152 L 82 190 Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
|
||||
<!-- TikTak — deux arcs (les deux temps), coche-flèche. Bleu #0076fb, orange #fc750c.
|
||||
Géométrie mesurée sur le visuel validé par Bruno (variante B). -->
|
||||
<path d="M 38.2 101.1 A 84 84 0 0 1 201.8 101.1" fill="none" stroke="#0076fb" stroke-width="28" stroke-linecap="round"/>
|
||||
<path d="M 201.5 140.3 A 84 84 0 0 1 38.5 140.3" fill="none" stroke="#fc750c" stroke-width="28" stroke-linecap="round"/>
|
||||
<polyline points="80,129 106,146 163,84" fill="none" stroke="#0076fb" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 128 80 L 163 84 L 167 122" fill="none" stroke="#0076fb" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
@@ -47,35 +47,9 @@
|
||||
href="/static/icons/icon-192x192.svg"
|
||||
/>
|
||||
|
||||
<!-- Boot splash CSS — inline so it applies BEFORE /static/style.css is
|
||||
available (desktop: embedded page served from tauri:// has no /static) -->
|
||||
<style id="boot-splash-css">
|
||||
body:not(.app-booted) > *:not(#boot-splash) { display: none !important; }
|
||||
#boot-splash {
|
||||
position: fixed; inset: 0; z-index: 2147483647;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
|
||||
color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
body.app-booted #boot-splash { display: none; }
|
||||
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.bs-logo {
|
||||
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.bs-spinner {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
border: 3px solid rgba(124, 58, 237, 0.25); border-top-color: #7c3aed;
|
||||
animation: bs-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes bs-spin { to { transform: rotate(360deg); } }
|
||||
.bs-status { font-size: 13px; color: #9aa3b2; min-height: 18px; }
|
||||
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/draco-barre.css" />
|
||||
<link rel="stylesheet" href="/static/draco-alertes.css" />
|
||||
<link rel="stylesheet" href="/static/draco-panneau.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
@@ -88,7 +62,7 @@
|
||||
id="hljs-theme-light"
|
||||
disabled
|
||||
/>
|
||||
<!-- defer: ne bloque pas le premier paint (le splash inline s'affiche immédiatement) -->
|
||||
<!-- defer: ne bloque pas le premier paint -->
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<!-- Langues highlight.js hors bundle "common" (enregistrement auto apres le core) -->
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dockerfile.min.js"></script>
|
||||
@@ -225,44 +199,6 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="has-draco-bar">
|
||||
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
|
||||
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
|
||||
sans ce splash, le squelette HTML brut serait visible pendant le
|
||||
démarrage du backend (FOUC 3-10 s). Styles 100% inline. -->
|
||||
<div id="boot-splash" role="status" aria-live="polite">
|
||||
<div class="bs-card">
|
||||
<div class="bs-logo">📖</div>
|
||||
<div class="bs-title">ObsiGate</div>
|
||||
<div class="bs-spinner" aria-hidden="true"></div>
|
||||
<div class="bs-status" id="boot-splash-status">Démarrage…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var done = false;
|
||||
function bootReady() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
document.body.classList.add("app-booted");
|
||||
var s = document.getElementById("boot-splash");
|
||||
if (s && s.parentNode) s.parentNode.removeChild(s);
|
||||
}
|
||||
window.__obsigateBootReady = bootReady;
|
||||
window.__setBootStatus = function (txt) {
|
||||
var el = document.getElementById("boot-splash-status");
|
||||
if (el) el.textContent = txt;
|
||||
};
|
||||
var poll = setInterval(function () {
|
||||
if (window.__OBSIGATE_BOOTED) { clearInterval(poll); bootReady(); }
|
||||
}, 80);
|
||||
// Filet de sécurité : si le CSS principal a chargé (origine backend réelle)
|
||||
// mais que le JS a échoué, révéler l'app après 10 s au lieu d'un splash infini.
|
||||
setTimeout(function () {
|
||||
var link = document.querySelector('link[href*="style.css"]');
|
||||
if (link && link.sheet) { clearInterval(poll); bootReady(); }
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
<div class="search-progress-bar" id="search-progress-bar">
|
||||
<div class="search-progress-bar__fill"></div>
|
||||
</div>
|
||||
@@ -332,35 +268,74 @@
|
||||
|
||||
<!-- Main Application (wrapped for show/hide) -->
|
||||
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
|
||||
évolution passe par une nouvelle version de commons.
|
||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
|
||||
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 (vers le Hub résolu par
|
||||
backend/draco_config, clé "hub", passé en urls.hub de la
|
||||
configuration ci-dessous) ; « Déconnexion » reste relayée.
|
||||
v0.2.0 : 6e onglet ShaarIt (data-app="shaarit", après Kanbante,
|
||||
avant l'onglet agent) — son href porte le défaut officiel et
|
||||
la réécriture DRACO_URLS ci-dessous le résout comme les
|
||||
autres (config backend/draco_config, dérivation -dev incluse) ;
|
||||
le CSS vendangé gagne la garde data-theme sur
|
||||
prefers-color-scheme (html:not([data-theme])).
|
||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
|
||||
Icônes logos v0.1.9 (2026-10-29) : les 5 tuiles sont les logos
|
||||
fournis, tels quels, servis depuis /static/img/logos/ (copie de
|
||||
ui/barre-apps/logos/ de dracodev-commons) ; l'entrée « Console Spark »
|
||||
du menu clone l'icône de l'onglet agent (draco-barre.js).
|
||||
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000, marque ✦
|
||||
→ logo Spark 26 px, bouton Spark rose supprimé (remplacé par
|
||||
l'avatar flottant bas-centre /static/img/avatar-spark.png,
|
||||
data-act="spark" → panneau Spark), Alertes = cloche seule.
|
||||
v0.1.11 (2026-10-29) : marque → étoile Spark monochrome
|
||||
/static/img/brand-spark.png 26 px (blanche sur barre foncée
|
||||
via [data-theme="dark"] d'ObsiGate, noire sur barre claire
|
||||
via le filtre brightness(0) du CSS vendangé — aucune
|
||||
surcharge propre nécessaire, la barre suit déjà
|
||||
data-theme) ; onglet agent GARDANT le logo rouge ;
|
||||
anneau rouge #c00000 (bordure 3 px) autour de l'avatar
|
||||
flottant, via le CSS vendangé.
|
||||
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
|
||||
(data-app="agent", défaut prod ; la réécriture DRACO_URLS
|
||||
couvre la clé "agent" comme les autres onglets), après Kanbante ;
|
||||
l'entrée « Console Spark » du menu de la marque est conservée. -->
|
||||
<header class="draco-bar" data-current="obsigate">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="shaarit" href="https://shaarit.dracodev.net/">
|
||||
<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="https://agent.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
@@ -379,24 +354,20 @@
|
||||
</div>
|
||||
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="draco-alertes-txt">Alertes</span>
|
||||
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
|
||||
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||
<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>
|
||||
<script>
|
||||
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé
|
||||
localement ; la recherche focus la recherche native
|
||||
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
|
||||
fédération / l'agent commun (phases suivantes). */
|
||||
d'ObsiGate ; Alertes unifiées : panneau draco-alertes.js
|
||||
(Fusion #69, agrégation à la demande, pas une fédération
|
||||
temps réel) ; Spark via le panneau commun. */
|
||||
/* #223 : les href des onglets portent les défauts officiels ;
|
||||
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
|
||||
backend depuis sa configuration) quand il est présent. */
|
||||
@@ -409,6 +380,13 @@
|
||||
})();
|
||||
window.DRACO_BAR_CONFIG = {
|
||||
current: "obsigate",
|
||||
/* Barre v0.3.0 (HUB-1) : la base du Hub vient de
|
||||
window.DRACO_URLS (clé "hub" de backend/draco_config :
|
||||
DRACO_HUB_URL explicite, dérivation -dev, défaut
|
||||
officiel) — le Hub n'a pas d'onglet, c'est par
|
||||
urls.hub que le menu compte construit ses liens
|
||||
Télécharger / À Propos / Aides / Profil. */
|
||||
urls: (function () { var u = window.DRACO_URLS && window.DRACO_URLS.hub; return u ? { hub: u.replace(/\/$/, "") + "/" } : {}; })(),
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
counts: {},
|
||||
onAction: function (act, el) {
|
||||
@@ -431,9 +409,10 @@
|
||||
if (b) b.click();
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
|
||||
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
|
||||
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
if (act === "deconnexion") { location.href = "/sso/logout?return_to=" + encodeURIComponent(location.origin + "/"); return; }
|
||||
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
|
||||
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||
@@ -460,6 +439,24 @@
|
||||
var av = document.getElementById("draco-avatar");
|
||||
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
|
||||
}
|
||||
/* Barre v0.6.0 (commons) : entrée « Administration »
|
||||
du menu compte. Le rôle global du compte chez
|
||||
ObsiGate EST la définition de l'administrateur
|
||||
de suite (c'est lui que la garde /admin/* du Hub
|
||||
vérifie, via ce même /api/auth/me) — ObsiGate le
|
||||
connaît nativement, il l'affirme à la barre.
|
||||
Tout autre rôle (ou échec) : pas d'affirmation,
|
||||
l'entrée reste masquée. */
|
||||
if (u && window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
|
||||
window.DRACO_BAR.setAdmin(u.role === "admin");
|
||||
}
|
||||
/* Barre v0.7.0 (commons, BUG-124) : le nom du
|
||||
compte est aussi poussé par setUser — le
|
||||
title seul ne resynchronisait ni l'aria-label
|
||||
ni un menu compte déjà construit. */
|
||||
if (u && nom && window.DRACO_BAR && window.DRACO_BAR.setUser) {
|
||||
window.DRACO_BAR.setUser(nom);
|
||||
}
|
||||
}).catch(function () { /* avatar générique conservé */ });
|
||||
});
|
||||
</script>
|
||||
@@ -6065,6 +6062,7 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/draco-alertes.js"></script>
|
||||
<script defer src="/static/draco-barre.js"></script>
|
||||
<script defer src="/static/draco-liens.js"></script>
|
||||
<script defer src="/static/draco-panneau.js"></script>
|
||||
|
||||
@@ -120,16 +120,8 @@ async function init() {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
try {
|
||||
await init();
|
||||
Sync.init();
|
||||
} finally {
|
||||
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
|
||||
// initialisée : le splash est retiré et le squelette stylé révélé.
|
||||
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
|
||||
window.__OBSIGATE_BOOTED = true;
|
||||
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
|
||||
}
|
||||
await init();
|
||||
Sync.init();
|
||||
// Show version in header — always visible, fallback before the async fetch
|
||||
populateVersions();
|
||||
fetch('/api/health')
|
||||
|
||||
@@ -131,15 +131,17 @@ const AuthManager = {
|
||||
_honorReturnTo() {
|
||||
try {
|
||||
const rt = new URLSearchParams(window.location.search).get("return_to");
|
||||
if (!rt) return;
|
||||
if (!rt) return false;
|
||||
const u = new URL(rt);
|
||||
const okHost = u.hostname === "dracodev.net"
|
||||
|| u.hostname.endsWith(".dracodev.net");
|
||||
if (u.protocol === "https:" && okHost
|
||||
&& u.hostname !== window.location.hostname) {
|
||||
window.location.replace(rt);
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* return_to illisible : on reste sur place */ }
|
||||
return false;
|
||||
},
|
||||
|
||||
getToken() {
|
||||
@@ -386,6 +388,17 @@ const AuthManager = {
|
||||
const section = document.getElementById("sidebar-user");
|
||||
if (!user || !this._authEnabled) {
|
||||
if (section) section.hidden = true;
|
||||
// Barre commune v0.6.0 (fusion #142) : sans identité
|
||||
// établie, l'entrée « Administration » du menu compte
|
||||
// reste masquée.
|
||||
if (window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
|
||||
window.DRACO_BAR.setAdmin(false);
|
||||
}
|
||||
// Barre commune v0.7.0 (BUG-124) : symétriquement, le nom
|
||||
// du compte est retiré de la barre (retour au neutre).
|
||||
if (window.DRACO_BAR && window.DRACO_BAR.setUser) {
|
||||
window.DRACO_BAR.setUser(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!section) return;
|
||||
@@ -427,6 +440,29 @@ const AuthManager = {
|
||||
});
|
||||
}
|
||||
|
||||
// Barre commune v0.6.0 (fusion #142) : l'entrée
|
||||
// « Administration » du menu compte suit le rôle natif du
|
||||
// compte — la définition même de l'administrateur de suite
|
||||
// (garde /admin/* du Hub), même source que la rangée
|
||||
// d'administration native ci-dessous. renderUserSection()
|
||||
// est appelée par showApp() pour toutes les sessions
|
||||
// établies (connexion, restauration, renouvellement
|
||||
// silencieux) ; le fetch d'amorçage d'index.html ne couvre
|
||||
// que les sessions déjà présentes au chargement de la page.
|
||||
if (window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
|
||||
window.DRACO_BAR.setAdmin(user.role === "admin");
|
||||
}
|
||||
// Barre commune v0.7.0 (BUG-124) : pousser aussi le NOM du
|
||||
// compte, pour le même motif — sans cet appel, la barre ne
|
||||
// connaissait l'identité que par le fetch d'amorçage
|
||||
// d'index.html (sessions déjà présentes au chargement) :
|
||||
// après un login SPA sans rechargement, le menu compte
|
||||
// affichait l'en-tête générique « Compte » et l'avatar
|
||||
// neutre « ✦ » jusqu'au rechargement forcé.
|
||||
if (window.DRACO_BAR && window.DRACO_BAR.setUser) {
|
||||
window.DRACO_BAR.setUser(name || null);
|
||||
}
|
||||
|
||||
const adminRow = document.getElementById("admin-menu-row");
|
||||
if (adminRow) {
|
||||
if (user.role === "admin") {
|
||||
@@ -467,8 +503,34 @@ const AuthManager = {
|
||||
return true;
|
||||
}
|
||||
|
||||
// BUG-125 : la page est-elle l'arrivée d'un /sso/authorize
|
||||
// (return_to présent) ? Si oui, le chemin court ci-dessous ne
|
||||
// doit PAS s'appliquer : il repartait vers l'application
|
||||
// sœur sans aucun échange serveur, donc sans laisser au
|
||||
// middleware de renouvellement (DracoRenewalMiddleware)
|
||||
// l'occasion de réémettre le cookie draco_session. Quand les
|
||||
// cookies access_token et draco_session ont disparu alors
|
||||
// que la session semble encore valide en sessionStorage
|
||||
// (cookies purgés séparément, profil restauré), le pont de
|
||||
// l'application sœur ne recevait AUCUN cookie : jeton absent
|
||||
// = formulaire nu, sans message, côté sœur. En forçant le
|
||||
// refresh silencieux ci-dessous (cookie refresh 7 j) suivi
|
||||
// de /api/auth/me — dont la requête porte le nouvel
|
||||
// access_token et dont la réponse traverse le middleware —
|
||||
// le cookie draco manquant ou invalide est réémis AVANT que
|
||||
// le retour soit honoré. Si le refresh échoue, l'écran de
|
||||
// connexion s'affiche comme avant (le return_to reste dans
|
||||
// l'URL et saveToken l'honorera après login frais).
|
||||
const retourDemande = new URLSearchParams(
|
||||
window.location.search).get("return_to");
|
||||
|
||||
// Auth enabled — check for existing session
|
||||
if (this.hasSession() && !this.isTokenExpired()) {
|
||||
if (this.hasSession() && !this.isTokenExpired() && !retourDemande) {
|
||||
// BUG-123 : session déjà établie au démarrage (jeton encore
|
||||
// valide) — si la page a été ouverte par /sso/authorize avec
|
||||
// un return_to, l'aller-retour SSO doit se refermer ici
|
||||
// aussi, pas seulement après un login frais (saveToken).
|
||||
if (this._honorReturnTo()) return true;
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
@@ -485,6 +547,16 @@ const AuthManager = {
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
// BUG-123 : même obligation après restauration par
|
||||
// refresh silencieux au démarrage — refreshAccessToken()
|
||||
// ne passe pas par saveToken, donc sans cet appel le
|
||||
// return_to d'un /sso/authorize restait sans effet et
|
||||
// l'utilisateur demeurait sur l'accueil ObsiGate au lieu
|
||||
// de revenir, connecté, à l'application demanderesse.
|
||||
// NB : seuls les chemins de démarrage d'initAuth honorent
|
||||
// le retour ; les refresh silencieux en cours de session
|
||||
// ne doivent jamais rediriger.
|
||||
if (this._honorReturnTo()) return true;
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
*
|
||||
* Règles de sécurité appliquées ici (jamais assouplies) :
|
||||
* - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur,
|
||||
* jamais un texte chiffré, jamais un nonce). Ce module n'affiche donc
|
||||
* jamais de valeur de secret après enregistrement : colonne valeur =
|
||||
* « •••••• » seulement, aucun bouton « afficher ».
|
||||
* jamais un texte chiffré, jamais un nonce). La valeur d'un secret
|
||||
* enregistré reste donc masquée (« •••••• ») par défaut ; seul le
|
||||
* gestionnaire du coffre (propriétaire, ou admin sur coffre
|
||||
* d'instance) peut la révéler, via POST .../secrets/{nom}/reveler
|
||||
* (session + gestion, chaque révélation auditée) : bouton
|
||||
* Afficher/Masquer dans le détail, ou « Copier la valeur » sans
|
||||
* l'afficher. La valeur est effacée dès qu'elle est masquée ou que
|
||||
* le détail change ; un simple lecteur par octroi utilisateur ne
|
||||
* révèle jamais.
|
||||
* - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un
|
||||
* journal console ou un log. Les variables contenant une valeur sont
|
||||
* effacées dès que l'opération est terminée (champ vidé, variable = null).
|
||||
@@ -14,10 +20,11 @@
|
||||
* consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une
|
||||
* modale, avec un bouton Copier et un avertissement ; la modale fermée,
|
||||
* ils sont perdus. Ce ne sont pas des valeurs de secret du coffre.
|
||||
* - La copie manuelle à usage unique (admin seulement, voir
|
||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||
* d'endpoint de distribution par session utilisateur côté backend.
|
||||
* - L'ancienne copie manuelle à usage unique par consommateur
|
||||
* temporaire a été supprimée : elle échouait systématiquement
|
||||
* (aucun octroi pour le consommateur temporaire, donc 403
|
||||
* octroi_absent à la distribution). Elle est remplacée par
|
||||
* « Copier la valeur », qui passe par l'endpoint de révélation.
|
||||
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
|
||||
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
|
||||
* en deux temps (import, puis purge confirmée). Aucune valeur ne
|
||||
@@ -52,6 +59,8 @@ let _coffresCache = null;
|
||||
let _coffresCacheAt = 0;
|
||||
const CACHE_MS = 30000;
|
||||
|
||||
let _valeurAffichee = null; // valeur révélée du détail courant, sinon null
|
||||
|
||||
const viewState = {
|
||||
coffres: [],
|
||||
currentSlug: null,
|
||||
@@ -354,6 +363,7 @@ function renderAlerts(host) {
|
||||
|
||||
function renderDetail(host) {
|
||||
const s = viewState.detail;
|
||||
_valeurAffichee = null;
|
||||
host.innerHTML = '';
|
||||
if (!s) {
|
||||
host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty'));
|
||||
@@ -377,7 +387,9 @@ function renderDetail(host) {
|
||||
const dl = el('dl', { class: 'coffres-dl' }, []);
|
||||
rows.forEach(([k, v]) => {
|
||||
dl.appendChild(el('dt', {}, [document.createTextNode(k)]));
|
||||
dl.appendChild(el('dd', {}, [document.createTextNode(v)]));
|
||||
const dd = el('dd', {}, [document.createTextNode(v)]);
|
||||
if (k === t('coffres.value')) dd.id = 'coffres-valeur';
|
||||
dl.appendChild(dd);
|
||||
});
|
||||
host.appendChild(dl);
|
||||
const actions = el('div', { class: 'coffres-actions' }, []);
|
||||
@@ -386,11 +398,36 @@ function renderDetail(host) {
|
||||
actions.appendChild(modalBtn(t('coffres.acces'), '', () => {
|
||||
document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}));
|
||||
actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s)));
|
||||
if (!isAdmin()) {
|
||||
actions.lastChild.disabled = true;
|
||||
actions.lastChild.title = t('coffres.manual_copy_admin_only');
|
||||
}
|
||||
const btnAfficher = modalBtn(t('coffres.show_value'), '', async () => {
|
||||
if (_valeurAffichee !== null) {
|
||||
_valeurAffichee = null;
|
||||
document.getElementById('coffres-valeur').textContent = '••••••';
|
||||
btnAfficher.textContent = t('coffres.show_value');
|
||||
return;
|
||||
}
|
||||
let valeur;
|
||||
try {
|
||||
valeur = await revelerValeur(s);
|
||||
} catch (e) {
|
||||
showToast(e.message || t('common.error'), 'error');
|
||||
return;
|
||||
}
|
||||
_valeurAffichee = valeur;
|
||||
document.getElementById('coffres-valeur').textContent = valeur;
|
||||
btnAfficher.textContent = t('coffres.hide_value');
|
||||
});
|
||||
actions.appendChild(btnAfficher);
|
||||
actions.appendChild(modalBtn(t('coffres.copy_value'), '', async () => {
|
||||
let valeur;
|
||||
try {
|
||||
valeur = await revelerValeur(s);
|
||||
} catch (e) {
|
||||
showToast(e.message || t('common.error'), 'error');
|
||||
return;
|
||||
}
|
||||
await copyText(valeur);
|
||||
valeur = null;
|
||||
}));
|
||||
actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s)));
|
||||
host.appendChild(actions);
|
||||
|
||||
@@ -819,55 +856,19 @@ function openCreateCoffreModal() {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Copie manuelle à usage unique, admin seulement.
|
||||
* Le backend n'expose pas (encore) de distribution par session : on passe
|
||||
* par un consommateur temporaire créé, utilisé une fois, puis révoqué
|
||||
* immédiatement. La valeur est copiée au presse-papiers sans jamais être
|
||||
* affichée, puis la variable est effacée, même en cas d'échec. */
|
||||
async function copieManuelle(s) {
|
||||
if (!isAdmin()) return;
|
||||
const ok = await showConfirm({
|
||||
title: t('coffres.manual_copy'),
|
||||
message: t('coffres.manual_copy_confirm'),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
let consommateurId = null;
|
||||
let valeur = null;
|
||||
try {
|
||||
const created = await api('/api/coffres/consommateurs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ nom: `copie-manuelle-${me()}-${Date.now()}` }),
|
||||
});
|
||||
consommateurId = created.id;
|
||||
const jeton = created.jeton;
|
||||
const res = await fetch('/api/coffres/distribuer', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jeton}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reference: s.reference,
|
||||
contexte_type: 'copie_manuelle',
|
||||
contexte_id: `copie-manuelle-${Date.now()}`,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
||||
valeur = data.valeur;
|
||||
await copyText(valeur);
|
||||
} catch (e) {
|
||||
showToast(e.message || t('common.error'), 'error');
|
||||
} finally {
|
||||
valeur = null;
|
||||
if (consommateurId) {
|
||||
try {
|
||||
await api(`/api/coffres/consommateurs/${encodeURIComponent(consommateurId)}`, { method: 'DELETE' });
|
||||
} catch { /* révocation best-effort, l'octroi reste révocable depuis Accès */ }
|
||||
}
|
||||
}
|
||||
/** Révélation manuelle (gestionnaire du coffre seulement, auditée
|
||||
* côté backend). Ne conserve rien : l'appelant affiche ou copie
|
||||
* puis efface. Un lecteur par octroi utilisateur reçoit 403. */
|
||||
async function revelerValeur(s) {
|
||||
const ref = (s.reference || '').replace(/^coffre:/, '');
|
||||
const [slug, nom] = ref.split('/');
|
||||
const res = await fetch(
|
||||
`/api/coffres/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(nom)}/reveler`,
|
||||
{ method: 'POST', credentials: 'include' },
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
||||
return data.valeur;
|
||||
}
|
||||
|
||||
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
||||
|
||||
@@ -59,11 +59,11 @@ function _sharedIcon(size = 16) {
|
||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
|
||||
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
|
||||
function _secretsIcon() {
|
||||
const s = document.createElement("span");
|
||||
s.className = "coffres-icon-lock";
|
||||
s.textContent = "\u{1F510}";
|
||||
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -209,6 +209,17 @@ function _openSecretsMenu(x, y) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vault déclaré en lecture seule (DIR_N_READONLY, champ « readonly » de
|
||||
* /api/vaults) : ses menus et boutons d'action ne doivent proposer aucune
|
||||
* mutation (renommer, déplacer, supprimer…) — le backend les refuse en
|
||||
* 403 de toute façon, l'UI le dit avant le clic.
|
||||
*/
|
||||
function vaultIsReadonly(vaultName) {
|
||||
const v = (state.allVaults || []).find((x) => x.name === vaultName);
|
||||
return !!(v && v.readonly);
|
||||
}
|
||||
|
||||
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
|
||||
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
||||
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
||||
@@ -356,11 +367,11 @@ async function refreshSidebarForContext() {
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(v.name);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||||
});
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
|
||||
|
||||
container.appendChild(vaultItem);
|
||||
|
||||
@@ -460,11 +471,11 @@ async function loadVaults() {
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(v.name);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||||
});
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
|
||||
|
||||
container.appendChild(vaultItem);
|
||||
|
||||
@@ -596,8 +607,8 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
// New item — create it
|
||||
if (item.type === "directory") {
|
||||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fragment.appendChild(dirItem);
|
||||
|
||||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||||
@@ -624,14 +635,14 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
dirItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||
});
|
||||
} else {
|
||||
const fileIconName = getFileIcon(item.name);
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fileItem, false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -649,7 +660,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
fileItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||
});
|
||||
|
||||
fragment.appendChild(fileItem);
|
||||
@@ -944,8 +955,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
if (item.type === "directory") {
|
||||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fragment.appendChild(dirItem);
|
||||
|
||||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||||
@@ -972,7 +983,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
dirItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(vaultName);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
|
||||
});
|
||||
} else {
|
||||
@@ -980,8 +991,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const isShared = sharedPaths.has(`${vaultName}::${item.path}`);
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fileItem, false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -999,7 +1010,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
fileItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(vaultName);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
|
||||
});
|
||||
|
||||
|
||||
@@ -2680,6 +2680,9 @@
|
||||
"coffres.copy_failed": "Copy failed.",
|
||||
"coffres.copy_generated": "Copy the generated value",
|
||||
"coffres.copy_reference": "Copy",
|
||||
"coffres.show_value": "Show",
|
||||
"coffres.hide_value": "Hide",
|
||||
"coffres.copy_value": "Copy value",
|
||||
"coffres.create_coffre_title": "New vault",
|
||||
"coffres.create_octroi": "Create a grant",
|
||||
"coffres.create_secret_title": "New secret",
|
||||
|
||||
@@ -2680,6 +2680,9 @@
|
||||
"coffres.copy_failed": "Copie impossible.",
|
||||
"coffres.copy_generated": "Copier la valeur générée",
|
||||
"coffres.copy_reference": "Copier",
|
||||
"coffres.show_value": "Afficher",
|
||||
"coffres.hide_value": "Masquer",
|
||||
"coffres.copy_value": "Copier la valeur",
|
||||
"coffres.create_coffre_title": "Nouveau coffre",
|
||||
"coffres.create_octroi": "Créer un octroi",
|
||||
"coffres.create_secret_title": "Nouveau secret",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||
.rp-input {
|
||||
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||
border: 1px solid var(--border, #888); border-radius: 8px; background: inherit; color: inherit;
|
||||
}
|
||||
.rp-input:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
|
||||
.rp-section { margin-bottom: 28px; }
|
||||
.rp-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
border-bottom: 1px solid var(--border, #888); padding-bottom: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||
.rp-count {
|
||||
font-size: .8rem; font-weight: 700; background: var(--accent, #3b82f6); color: #fff;
|
||||
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||
}
|
||||
.rp-count:empty { display: none; }
|
||||
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||
.rp-state { opacity: .75; margin: 8px 0; }
|
||||
.rp-error { opacity: 1; }
|
||||
.rp-item {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||
.rp-item:focus-visible { outline: 2px solid var(--accent, #3b82f6); }
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border, #888); margin-bottom: 20px;
|
||||
}
|
||||
.rp-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||
color: inherit; opacity: .7; white-space: nowrap;
|
||||
}
|
||||
.rp-tab:hover { opacity: 1; }
|
||||
.rp-tab.active { opacity: 1; border-bottom-color: var(--accent, #3b82f6); font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
|
||||
.rp-tab-count {
|
||||
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||
}
|
||||
.rp-tab-count:empty { display: none; }
|
||||
.rp-section[hidden] { display: none; }
|
||||
.rp-all[hidden] { display: none; }
|
||||
|
||||
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||
.layout > .rp-page {
|
||||
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.layout > .rp-page { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||
* .content-area) et cette navigation, calée sur les variables de
|
||||
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||
* la SPA (voir le commentaire dans recherche.html). */
|
||||
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||
.rp-og-nav a {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||
.rp-og-nav a[aria-current="page"] {
|
||||
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||
}
|
||||
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||
|
||||
/* ---- Thème (défaut 4) : la page suit le réglage ObsiGate. ----
|
||||
* L'étape 1 (script synchrone en tête de recherche.html) pose
|
||||
* data-theme = mode persisté ; l'étape 2 (themes.js) pose la palette
|
||||
* complète en variables inline sur :root. Les règles .rp-* ci-dessus
|
||||
* utilisent donc var(--border) / var(--accent) avec les anciennes
|
||||
* valeurs en repli, et le reste hérite déjà du corps (style.css).
|
||||
* color-scheme : les contrôles natifs (barres de défilement, champ
|
||||
* de recherche) suivent le mode au lieu de celui de l'OS.
|
||||
* Barre : draco-barre.css (fichier commun vendangé, NON modifié ici)
|
||||
* ne connaît que [data-theme="dark"] ; sans les règles ci-dessous,
|
||||
* sa media query prefers-color-scheme:dark imposerait une barre
|
||||
* sombre sur une page claire (OS sombre + mode ObsiGate clair), et
|
||||
* les modes high-contrast/sepia n'auraient aucune déclinaison.
|
||||
* Les valeurs reprennent les défauts clairs de draco-barre.css et
|
||||
* les générateurs _genHighContrast/_genSepia de themes.js. */
|
||||
[data-theme="dark"], [data-theme="high-contrast"] { color-scheme: dark; }
|
||||
[data-theme="light"], [data-theme="sepia"] { color-scheme: light; }
|
||||
[data-theme="light"] .draco-bar { --bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3; --ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9; }
|
||||
[data-theme="high-contrast"] .draco-bar { --bg: #000000; --surface: #0a0a0a; --surface-2: #141414; --surface-3: #000000; --ink: #ffffff; --ink-2: #cccccc; --ink-3: #888888; --line: #444444; }
|
||||
[data-theme="sepia"] .draco-bar { --bg: #f5ecd5; --surface: #eee3c8; --surface-2: #e8dcc0; --surface-3: #f5ecd5; --ink: #3d2e1a; --ink-2: #6b5a3e; --ink-3: #9a8a6a; --line: #c8b890; }
|
||||
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rechercher partout — ObsiGate</title>
|
||||
<!-- Gabarit ObsiGate (chantier 2+3) : la page /recherche est servie
|
||||
hors SPA (route déclarée avant le catch-all, comme /admin.html),
|
||||
elle reprend donc les feuilles de l'app (style.css + barre) et
|
||||
la structure de index.html (barre de fusion, en-tête, sidebar,
|
||||
zone de contenu) au lieu de la page nue précédente.
|
||||
Jetons {{URL_*}} remplacés par la route /recherche (même contrat
|
||||
que le fragment de barre) ; servie brute via /static, la page
|
||||
retomberait sur les défauts prod (les jetons sont ignorés par
|
||||
le JS) — jamais d'URL inventée.
|
||||
La barre ci-dessous est une copie statique du snippet figé de
|
||||
index.html (commons v0.2.0 + chantier 5 fusion #69/#71, sans
|
||||
bump) : les onglets sont des liens réels, le bouton central
|
||||
replace le curseur dans la boîte (recherche.js,
|
||||
clic data-act="palette"). Re-vendoring alignement recherche :
|
||||
5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
|
||||
remplacé par la route /recherche comme les 4 autres) et
|
||||
icône SVG du bouton Alertes, repris d'index.html. Les menus,
|
||||
le panneau Spark, les alertes et le domaine restent des
|
||||
comportements de la SPA et ne sont pas rejoués ici.
|
||||
Sidebar statique (navigation de l'app) : les vaults, tags et
|
||||
récents dynamiques vivent dans la SPA, pas sur cette page. -->
|
||||
<script>
|
||||
/* Thème ObsiGate, étape 1 (synchrone, avant les feuilles) : poser
|
||||
data-theme = mode persisté AVANT la première peinture, sinon la
|
||||
page flasherait en sombre puis basculerait. Sources lues :
|
||||
'obsigate-theme-mode' (SPA modulaire, js/ui.js initTheme) puis,
|
||||
à défaut, 'obsigate-theme' quand il vaut déjà un mode (valeur
|
||||
historique de l'app monolithique) ; toute autre valeur — clé de
|
||||
thème comme 'dracula', mode inconnu, stockage indisponible —
|
||||
retombe sur 'dark', le défaut SPA. La palette complète (clé)
|
||||
est appliquée à l'étape 2 en fin de page. */
|
||||
(function () {
|
||||
var MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
||||
var mode = null;
|
||||
try { mode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) { mode = null; }
|
||||
if (MODES.indexOf(mode) === -1) {
|
||||
try {
|
||||
var legacy = window.localStorage.getItem('obsigate-theme');
|
||||
if (MODES.indexOf(legacy) !== -1) { mode = legacy; }
|
||||
} catch (e) { /* stockage indisponible : repli sombre */ }
|
||||
}
|
||||
if (MODES.indexOf(mode) === -1) { mode = 'dark'; }
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/draco-barre.css">
|
||||
<link rel="stylesheet" href="/static/recherche.css">
|
||||
</head>
|
||||
<body class="has-draco-bar rp-og">
|
||||
<header class="draco-bar" data-current="obsigate">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark"><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="{{URL_OBSIGATE}}">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="{{URL_FLOWDECK}}">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="{{URL_TIKTAK}}">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="kanbante" href="{{URL_KANBANTE}}">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="shaarit" href="{{URL_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="{{URL_AGENT}}">
|
||||
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
|
||||
<span class="draco-search-ico">⌕</span>
|
||||
<span class="draco-search-txt">Rechercher partout…</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-droite">
|
||||
<div class="draco-domaines" role="group" aria-label="Domaine">
|
||||
<span class="draco-domaines-lbl">Domaine</span>
|
||||
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
|
||||
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
|
||||
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
|
||||
</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-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</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 id="app" class="app-container">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-logo">ObsiGate</div>
|
||||
<a href="/">Retour à l'application</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main-body">
|
||||
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation ObsiGate">
|
||||
<nav class="rp-og-nav">
|
||||
<a href="/">Tableau de bord</a>
|
||||
<a href="/recherche" aria-current="page">Rechercher partout</a>
|
||||
<p class="rp-og-note">La navigation par vaults (fichiers, tags, récents) vit dans l'application ObsiGate.</p>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="content-wrapper">
|
||||
<div class="content-area">
|
||||
<main class="rp-page" id="recherche" data-host="obsigate"
|
||||
data-base-obsigate="{{URL_OBSIGATE}}"
|
||||
data-base-flowdeck="{{URL_FLOWDECK}}"
|
||||
data-base-tiktak="{{URL_TIKTAK}}"
|
||||
data-base-kanbante="{{URL_KANBANTE}}"
|
||||
data-base-shaarit="{{URL_SHAARIT}}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante, bookmarks ShaarIt — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value=""
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
|
||||
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="shaarit">ShaarIt <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
|
||||
</nav>
|
||||
<section class="rp-section" data-rp-section="obsigate">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="flowdeck">
|
||||
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="tiktak">
|
||||
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="kanbante">
|
||||
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="shaarit">
|
||||
<div class="rp-head"><h2>ShaarIt — bookmarks</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="console">
|
||||
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/recherche.js"></script>
|
||||
<script type="module">
|
||||
/* Thème ObsiGate, étape 2 (différée) : appliquer la palette
|
||||
complète via le moteur de thèmes de la SPA (themes.js), seule
|
||||
source de vérité des 16 thèmes, des modes high-contrast/sepia
|
||||
et des thèmes personnalisés — aucune table recopiée ici.
|
||||
initThemes() relit localStorage et pose les variables inline
|
||||
sur :root + data-theme = mode, comme au démarrage de la SPA
|
||||
(js/sync.js init). Garde historique : si 'obsigate-theme'
|
||||
vaut encore un mode ('dark'/'light', app monolithique) sans
|
||||
'obsigate-theme-mode', on garde le mode de l'étape 1 —
|
||||
initThemes() y verrait une clé inconnue et retomberait sur
|
||||
'spark'/'dark'. Échec de chargement silencieux : la page
|
||||
garde alors le mode de l'étape 1, recherche.js n'est pas
|
||||
touché (fichier partagé avec les 3 autres hôtes). */
|
||||
(function () {
|
||||
var saved = null;
|
||||
var savedMode = null;
|
||||
try { saved = window.localStorage.getItem('obsigate-theme'); } catch (e) {}
|
||||
try { savedMode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) {}
|
||||
if (savedMode === null && (saved === 'dark' || saved === 'light')) { return; }
|
||||
import('/static/js/themes.js').then(function (mod) {
|
||||
if (mod && typeof mod.initThemes === 'function') { mod.initThemes(); }
|
||||
}).catch(function () {});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -8493,7 +8493,7 @@ body.popup-mode .content-area {
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.35);
|
||||
width: fit-content;
|
||||
max-width: 95%;
|
||||
z-index: 50;
|
||||
z-index: 66; /* au-dessus de .draco-spark-flottant (65) sinon la barre flottante DRACO intercepte les clics des flèches de recherche */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.results-bar-count {
|
||||
@@ -14372,7 +14372,8 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
.coffres-alerts { margin: 12px 0; }
|
||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||
.coffres-icon-lock { font-size: 16px; line-height: 1; }
|
||||
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
|
||||
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
|
||||
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.81.0",
|
||||
"version": "2.86.3",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -25,7 +25,7 @@ async function login(page) {
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
// Wait until app init() resolved (all handlers bound).
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function authEnabled(page) {
|
||||
|
||||
@@ -26,7 +26,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -25,7 +25,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
async function goHome(page) {
|
||||
await page.goto(BASE);
|
||||
// Wait until app init() resolved (all handlers bound, incl. search).
|
||||
await page.waitForFunction(() => (window as any).__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -57,7 +57,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
|
||||
@@ -117,7 +117,7 @@ test.describe('XSS — lecteur markdown (BUG-021)', () => {
|
||||
await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
|
||||
|
||||
await page.goto(BASE);
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
await openFile(page, VAULT, XSS_FILE);
|
||||
|
||||
const content = page.locator('#content-area');
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2 + chantier 5).
|
||||
*
|
||||
* Tests du module partagé frontend/draco-alertes.js (logique pure,
|
||||
* sans DOM) + vérification du gabarit livré frontend/index.html.
|
||||
* Copie vendangée byte-identique de Projets/dracodev-commons
|
||||
* ui/alertes/ (chantier 5) — seul le chargement change (ESM ici).
|
||||
* Le payload console n'est qu'un EXEMPLE DE FORMAT (voir README
|
||||
* commons) : aucun endpoint console n'est publié sur agent-commun
|
||||
* main au moment du chantier.
|
||||
*
|
||||
* Usage: node tests/frontend/draco-alertes.test.mjs
|
||||
*/
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js"));
|
||||
|
||||
function sampleResults() {
|
||||
return {
|
||||
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
}) },
|
||||
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]) },
|
||||
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
}) },
|
||||
obsigate: { items: [] },
|
||||
console: { items: [], sourceMissing: true },
|
||||
};
|
||||
}
|
||||
function allOf(results) {
|
||||
const all = [];
|
||||
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
|
||||
(results[app] && results[app].items || []).forEach((a) => all.push(a));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
|
||||
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
|
||||
assert.equal(t.TAB_LABELS.console, "Console Spark");
|
||||
});
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
|
||||
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(prod.agent, "https://agent.dracodev.net/");
|
||||
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
|
||||
const explicit = t.resolveUrls({
|
||||
hostname: "tiktak-dev.dracodev.net",
|
||||
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||
});
|
||||
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||
const href = t.resolveUrls({
|
||||
hostname: "example.org",
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
|
||||
});
|
||||
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||
assert.equal(href.agent, "https://agent.dracodev.net/");
|
||||
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
|
||||
});
|
||||
|
||||
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].state, "active");
|
||||
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
|
||||
assert.equal(items[1].active, false);
|
||||
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
|
||||
});
|
||||
|
||||
test("normalizeTiktak: lien tache, sinon page notifications", () => {
|
||||
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]);
|
||||
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
|
||||
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
|
||||
assert.equal(items[0].active, true);
|
||||
});
|
||||
|
||||
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
|
||||
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.ok(items.every((i) => i.active === false));
|
||||
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
|
||||
assert.match(items[0].title, /#69/);
|
||||
});
|
||||
|
||||
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||
const list = [
|
||||
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
|
||||
{ app: "kanbante", active: false, date: "" },
|
||||
];
|
||||
const sorted = t.sortAlerts(list);
|
||||
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
|
||||
assert.equal(sorted[0].active, true);
|
||||
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
|
||||
assert.equal(sorted[3].date, "");
|
||||
assert.equal(t.countActive(list), 2);
|
||||
});
|
||||
|
||||
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r);
|
||||
assert.equal(tout.items.length, 2);
|
||||
assert.ok(tout.items.every((a) => a.active));
|
||||
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
|
||||
const fd = t.scopeAlerts(all, "flowdeck", r);
|
||||
assert.equal(fd.items.length, 1);
|
||||
assert.equal(fd.items[0].app, "flowdeck");
|
||||
const hist = t.scopeAlerts(all, "historique", r);
|
||||
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
|
||||
assert.ok(hist.items.every((a) => !a.active));
|
||||
const og = t.scopeAlerts(all, "obsigate", r);
|
||||
assert.equal(og.items.length, 0);
|
||||
const cs = t.scopeAlerts(all, "console", r);
|
||||
assert.equal(cs.items.length, 0);
|
||||
assert.equal(cs.consoleMissing, true);
|
||||
});
|
||||
|
||||
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
|
||||
const r = sampleResults();
|
||||
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
|
||||
const hist = t.scopeAlerts(allOf(r), "historique", r);
|
||||
assert.equal(hist.errors.length, 1);
|
||||
assert.equal(hist.errors[0].app, "flowdeck");
|
||||
const tout = t.scopeAlerts(allOf(r), "tout", r);
|
||||
assert.equal(tout.errors.length, 1);
|
||||
});
|
||||
|
||||
test("countByTab: compteurs par onglet sur l'echantillon", () => {
|
||||
const r = sampleResults();
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.deepEqual(c, {
|
||||
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
|
||||
kanbante: 0, console: 0, historique: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r).items;
|
||||
assert.equal(t.filterAlerts(tout, "budget").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
|
||||
assert.equal(t.filterAlerts(tout, "tache").length, 1);
|
||||
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
|
||||
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
|
||||
const hist = t.scopeAlerts(all, "historique", r).items;
|
||||
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "").length, tout.length);
|
||||
});
|
||||
|
||||
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
|
||||
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
|
||||
})[0];
|
||||
assert.ok(!t.metaLine(kb).includes("moved"));
|
||||
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
|
||||
assert.equal(t.displayState({ active: true }), "Active");
|
||||
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
|
||||
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
|
||||
assert.match(t.metaLine(sansDate), /sans date/);
|
||||
});
|
||||
|
||||
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
|
||||
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
|
||||
assert.equal(t.parseAlertTab("#alertes=console"), "console");
|
||||
assert.equal(t.parseAlertTab("#alertes"), "tout");
|
||||
assert.equal(t.parseAlertTab("#autre"), null);
|
||||
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
|
||||
assert.equal(t.parseAlertTab(""), null);
|
||||
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
|
||||
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
|
||||
});
|
||||
|
||||
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
|
||||
const items = t.normalizeConsole("https://agent.dracodev.net/", {
|
||||
alerts: [
|
||||
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
|
||||
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
|
||||
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
|
||||
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
|
||||
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 5);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[1].active, true);
|
||||
assert.equal(items[2].active, true);
|
||||
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
|
||||
assert.equal(items[3].active, false);
|
||||
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
|
||||
assert.equal(items[0].url, "https://agent.dracodev.net/");
|
||||
const r = sampleResults();
|
||||
r.console = { items };
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.equal(c.console, 4);
|
||||
assert.equal(c.tout, 6);
|
||||
assert.equal(c.historique, 5);
|
||||
});
|
||||
|
||||
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
|
||||
assert.equal(t.consoleSourceUrl({}), null);
|
||||
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
|
||||
"https://agent-dev.dracodev.net/api/alertes");
|
||||
});
|
||||
|
||||
test("index.html: placeholder Alertes disparu, module charge", () => {
|
||||
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
|
||||
assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false);
|
||||
assert.equal(html.includes("/static/draco-alertes.js"), true);
|
||||
assert.equal(html.includes("/static/draco-alertes.css"), true);
|
||||
assert.equal(html.includes("DRACO_ALERTES.open()"), true);
|
||||
});
|
||||
|
||||
test("index.html chantier 5: onglet Console Spark en dernier, apres Kanbante", () => {
|
||||
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
|
||||
const nav = /<nav class="draco-apptabs"[^>]*>([\s\S]*?)<\/nav>/.exec(html)[1];
|
||||
const apps = [...nav.matchAll(/data-app="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual(apps, ["obsigate", "flowdeck", "tiktak", "kanbante", "agent"]);
|
||||
assert.ok(nav.includes('data-app="agent" href="https://agent.dracodev.net/"'));
|
||||
assert.ok(nav.includes("<strong>Console Spark</strong>"));
|
||||
// La réécriture DRACO_URLS couvre la clé "agent" (même boucle que les autres onglets).
|
||||
assert.ok(html.includes('window.DRACO_URLS[a.dataset.app]'));
|
||||
});
|
||||
|
||||
test("draco-alertes.js/.css chantier 5: onglets, filtre, etat console honnete", () => {
|
||||
const js = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.js"), "utf8");
|
||||
assert.ok(js.includes("draco-alertes-tab"));
|
||||
assert.ok(js.includes("historique"));
|
||||
assert.ok(js.includes("source indisponible"));
|
||||
assert.ok(js.includes("DRACO_ALERTES_CONSOLE_URL"));
|
||||
const css = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.css"), "utf8");
|
||||
assert.ok(css.includes(".draco-alertes-tab"));
|
||||
assert.ok(css.includes(".draco-alertes-filtre"));
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — tests de la logique pure de la page « Rechercher partout »
|
||||
* (fusion #68), sans DOM ni jsdom : résolution des bases, liens
|
||||
* profonds et normalisation des 4 formats d'API.
|
||||
*
|
||||
* Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js
|
||||
* est "type": "module") ; ses helpers sont exposés via
|
||||
* globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js");
|
||||
|
||||
await import(pathToFileURL(JS_FILE).href);
|
||||
const rp = globalThis.RecherchePartout;
|
||||
assert.ok(rp, "RecherchePartout exposé");
|
||||
|
||||
let n = 0;
|
||||
const ok = (cond, label) => {
|
||||
assert.ok(cond, label);
|
||||
n += 1;
|
||||
};
|
||||
|
||||
ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu");
|
||||
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||
|
||||
const b = rp.resolveBases({
|
||||
host: "og-dev.dracodev.net",
|
||||
explicit: { tiktak: "https://tiktak-perso.example/" },
|
||||
barUrls: {},
|
||||
tabHrefs: {},
|
||||
});
|
||||
ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire");
|
||||
ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant");
|
||||
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
|
||||
ok(b.shaarit === "https://shaarit-dev.dracodev.net/", "base shaarit dev");
|
||||
ok(
|
||||
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
|
||||
"path ObsiGate",
|
||||
);
|
||||
|
||||
const items = rp.normalize(
|
||||
"obsigate",
|
||||
{ results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] },
|
||||
"https://og-dev.dracodev.net/",
|
||||
"idée",
|
||||
);
|
||||
ok(
|
||||
items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md",
|
||||
"lien profond ObsiGate",
|
||||
);
|
||||
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=",
|
||||
);
|
||||
|
||||
/* normalize ShaarIt (commons v0.2.1) : lien profond shaare/<shorturl>,
|
||||
* contexte privé + tags, repli sur l'URL du bookmark sans shorturl */
|
||||
const signets = 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(signets[0].url === "https://shaarit-dev.dracodev.net/shaare/xYz9", "lien profond ShaarIt (shaare/<shorturl>)");
|
||||
ok(signets[0].context === "Bookmark · privé · recherche, web", "contexte ShaarIt (privé + tags)");
|
||||
ok(signets[0].snippet === "moteur", "extrait ShaarIt = description");
|
||||
const sansShort = rp.normalize(
|
||||
"shaarit",
|
||||
{ links: [{ title: "", url: "https://exemple.fr", tags: [], shorturl: "" }] },
|
||||
"https://shaarit.dracodev.net/",
|
||||
"x",
|
||||
);
|
||||
ok(sansShort[0].url === "https://exemple.fr" && sansShort[0].title === "https://exemple.fr", "ShaarIt sans shorturl : repli URL + titre");
|
||||
|
||||
|
||||
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
|
||||
|
||||
/* parseTab */
|
||||
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
|
||||
ok(rp.parseTab("shaarit") === "shaarit", "onglet shaarit valide");
|
||||
ok(rp.parseTab("console") === "console", "onglet console valide");
|
||||
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
|
||||
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
|
||||
|
||||
/* visibleKeys : Tout = les 5 sections groupées, sans la console */
|
||||
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "Tout = 5 sections, pas la console");
|
||||
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("shaarit")) === JSON.stringify(["shaarit"]), "onglet shaarit = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "onglet inconnu -> Tout");
|
||||
|
||||
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
|
||||
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
|
||||
|
||||
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
|
||||
let c = rp.tabCounts({
|
||||
obsigate: { status: "loading", total: 0 },
|
||||
flowdeck: { status: "ok", total: 3 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
shaarit: { status: "ok", total: 2 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.obsigate === "…", "compteur … pendant le chargement");
|
||||
ok(c.shaarit === "2", "compteur shaarit");
|
||||
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
|
||||
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
|
||||
ok(c.all === "…", "Tout = … tant qu'une section charge");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "ok", total: 5 },
|
||||
flowdeck: { status: "ok", total: 2 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
shaarit: { status: "ok", total: 1 },
|
||||
console: { status: "ok", total: 4 },
|
||||
});
|
||||
ok(c.all === "8", "Tout = somme des totaux connus (5 sections)");
|
||||
ok(c.console === "4", "compteur console");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "error", total: 0 },
|
||||
flowdeck: { status: "error", total: 0 },
|
||||
tiktak: { status: "error", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
shaarit: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.all === "", "Tout vide si les 5 sections sont en erreur");
|
||||
|
||||
/* buildSearchUrl : état onglet partageable */
|
||||
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
|
||||
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
|
||||
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
|
||||
|
||||
/* searchPath complet (onglet) vs tronqué (Tout) */
|
||||
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
|
||||
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
|
||||
|
||||
/* normalize complet vs tronqué */
|
||||
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
|
||||
|
||||
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
|
||||
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
|
||||
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
|
||||
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
|
||||
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
|
||||
|
||||
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
|
||||
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
|
||||
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
|
||||
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
|
||||
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
|
||||
|
||||
|
||||
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
|
||||
let rc;
|
||||
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
|
||||
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
|
||||
rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
|
||||
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
|
||||
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
|
||||
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
|
||||
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
|
||||
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
|
||||
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
|
||||
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
|
||||
] }, "https://agent-dev.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
|
||||
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
|
||||
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
|
||||
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
|
||||
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
|
||||
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
|
||||
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
|
||||
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
|
||||
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
|
||||
rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
|
||||
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
|
||||
] }, "https://agent.dracodev.net/");
|
||||
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
|
||||
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
|
||||
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
||||
@@ -112,8 +112,10 @@ function testModulesHaveImports() {
|
||||
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
{ file: 'recherche.js', reason: 'standalone — vanilla sans dépendance, vendangé tel quel par chaque hôte (#68)', noExport: true },
|
||||
];
|
||||
const expectNoImport = new Set(expected.map(e => e.file));
|
||||
const expectNoExport = new Set(expected.filter(e => e.noExport).map(e => e.file));
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(JS_DIR, file), 'utf-8');
|
||||
@@ -123,7 +125,7 @@ function testModulesHaveImports() {
|
||||
if (!expectNoImport.has(file)) {
|
||||
assert.ok(hasImport, `${file} should have at least one import`);
|
||||
}
|
||||
if (file !== 'state.js' && file !== 'app.js') {
|
||||
if (file !== 'state.js' && file !== 'app.js' && !expectNoExport.has(file)) {
|
||||
assert.ok(hasExport, `${file} should have at least one export`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ from backend.coffre_crypto import (
|
||||
)
|
||||
from backend.coffre_store import (
|
||||
CoffreExisteError,
|
||||
create_octroi,
|
||||
list_secrets,
|
||||
SecretExpireError,
|
||||
ValidationError,
|
||||
create_coffre,
|
||||
@@ -131,6 +133,17 @@ def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VAL
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _creer_secret(client, slug="personnel", nom="second",
|
||||
valeur=VALEUR_TEST_2):
|
||||
resp = client.post(
|
||||
f"/api/coffres/{slug}/secrets",
|
||||
json={"nom": nom, "type": "token", "service": "Test",
|
||||
"valeur": valeur},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -678,3 +691,143 @@ class TestConsommateurAvecOctrois:
|
||||
assert len(resp.json()["octrois"]) == 1
|
||||
assert len(resp.json()["erreurs"]) == 1
|
||||
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Liste des distribuables pour un consommateur (issue #43)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDistribuables:
|
||||
def _lister(self, client, jeton=None):
|
||||
headers = {}
|
||||
if jeton is not None:
|
||||
headers["Authorization"] = f"Bearer {jeton}"
|
||||
return client.get("/api/coffres/distribuables", headers=headers)
|
||||
|
||||
def test_ne_liste_que_les_octrois_actifs_metadonnees_seules(
|
||||
self, client, env_coffre):
|
||||
_creer_coffre_secret(client) # personnel/gitea-token
|
||||
_creer_secret(client, nom="sans-octroi")
|
||||
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
|
||||
resp = self._lister(client, jeton)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == [{
|
||||
"reference": "coffre:personnel/gitea-token",
|
||||
"coffre": "personnel",
|
||||
"nom": "gitea-token",
|
||||
"version": 1,
|
||||
}]
|
||||
assert VALEUR_TEST not in resp.text
|
||||
assert VALEUR_TEST_2 not in resp.text
|
||||
assert "valeur" not in resp.text
|
||||
# Octroi révoqué : le secret disparaît, sans erreur.
|
||||
client.delete(f"/api/coffres/octrois/{octroi['id']}")
|
||||
assert self._lister(client, jeton).json() == []
|
||||
|
||||
def test_octroi_expire_exclu_et_coffre_wide_inclus(
|
||||
self, client, env_coffre):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
_creer_coffre_secret(client)
|
||||
_creer_secret(client, nom="second")
|
||||
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
|
||||
# Octroi expiré (hier) sur « second » : exclu.
|
||||
coffre = get_coffre_or_404("personnel")
|
||||
second = [x for x in list_secrets(coffre["id"])
|
||||
if x["nom"] == "second"][0]
|
||||
create_octroi(coffre, "consommateur", consommateur["id"],
|
||||
second["id"], "bruno",
|
||||
expiration=(datetime.now(timezone.utc)
|
||||
- timedelta(days=1)).isoformat())
|
||||
# Octroi coffre-wide actif : couvre tous les secrets.
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/octrois",
|
||||
json={"cible_type": "consommateur",
|
||||
"cible_id": consommateur["id"],
|
||||
"secret_nom": None},
|
||||
)
|
||||
# Si l'API refuse le coffre-wide sans nom, on le crée direct.
|
||||
if resp.status_code != 200:
|
||||
create_octroi(coffre, "consommateur", consommateur["id"],
|
||||
None, "bruno")
|
||||
refs = {x["reference"]
|
||||
for x in self._lister(client, jeton).json()}
|
||||
assert "coffre:personnel/gitea-token" in refs
|
||||
assert "coffre:personnel/second" in refs
|
||||
|
||||
def test_auth_consommateur_uniquement(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
_creer_consommateur_octroi(client)
|
||||
# Sans jeton : 401 (la session utilisateur ne suffit pas).
|
||||
assert self._lister(client).status_code == 401
|
||||
# Jeton inconnu : 401, sans révéler d'inventaire.
|
||||
assert self._lister(client, "jeton-inconnu").status_code == 401
|
||||
# Consommateur sans octroi : liste vide, pas d'erreur.
|
||||
resp = client.post("/api/coffres/consommateurs",
|
||||
json={"nom": "autre-app"})
|
||||
autre_jeton = resp.json()["jeton"]
|
||||
assert self._lister(client, autre_jeton).json() == []
|
||||
|
||||
def test_consommateur_revoque_et_flag_desactive(
|
||||
self, client, env_coffre, monkeypatch):
|
||||
_creer_coffre_secret(client)
|
||||
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
|
||||
client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
|
||||
resp = self._lister(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "consommateur_revoque"
|
||||
# Flag désactivé : 503 comme les autres routes.
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||
assert self._lister(client, jeton).status_code == 503
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Révélation manuelle (panneau détail : Afficher / Copier la valeur)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRevelation:
|
||||
def test_proprietaire_revele_et_audit_sans_valeur(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||
assert resp.status_code == 200, resp.text
|
||||
corps = resp.json()
|
||||
assert corps["valeur"] == VALEUR_TEST
|
||||
assert corps["reference"] == "coffre:personnel/gitea-token"
|
||||
assert corps["version"] == 1
|
||||
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||
assert "revelation_manuelle" in audit
|
||||
assert VALEUR_TEST not in audit
|
||||
|
||||
def test_lecteur_octroi_utilisateur_ne_revele_pas(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/personnel/octrois", json={
|
||||
"cible_type": "utilisateur", "cible_id": "alice",
|
||||
"secret_nom": "gitea-token"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
_connecter(client, "alice", role="user")
|
||||
# Voit les métadonnées…
|
||||
assert client.get("/api/coffres/personnel/secrets").status_code == 200
|
||||
# …mais ne révèle jamais la valeur.
|
||||
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_secret_inexistant_404(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/personnel/secrets/absent/reveler")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_secret_expire_410(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
resp = client.post("/api/coffres/personnel/secrets", json={
|
||||
"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST,
|
||||
"expiration": hier})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post("/api/coffres/personnel/secrets/vieux-jeton/reveler")
|
||||
assert resp.status_code == 410
|
||||
assert resp.json()["detail"] == "secret_expire"
|
||||
|
||||
def test_flag_desactive_503(self, client, monkeypatch):
|
||||
_creer_coffre_secret(client)
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# tests/test_comptes_machine.py — HUB-13 (fusion #127)
|
||||
# API d'administration des comptes machine-à-machine
|
||||
# (/api/admin/machine/comptes), protégée par la clé dédiée
|
||||
# DRACO_ADMIN_MACHINE_KEY : lecture/écriture, désactivation avec
|
||||
# révocation des sessions, réinitialisations du mot de passe (avec
|
||||
# changement forcé) et du MFA, suppression, journalisation d'audit.
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
MACHINE_KEY = "cle-admin-machine-test"
|
||||
MACHINE_HEADERS = {"X-Draco-Machine-Key": MACHINE_KEY,
|
||||
"X-Draco-Actor": "hub-test"}
|
||||
BASE = "/api/admin/machine/comptes"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine_client():
|
||||
"""TestClient avec auth active, données isolées, clés machine posées."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
from backend.auth.mfa import generate_secret
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1",
|
||||
"username": "admin",
|
||||
"display_name": "admin",
|
||||
"password_hash": hash_password("chab30"),
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"mfauser": {
|
||||
"id": "user-mfa",
|
||||
"username": "mfauser",
|
||||
"display_name": "Utilisateur MFA",
|
||||
"password_hash": hash_password("motdepasse-mfa"),
|
||||
"role": "user",
|
||||
"vaults": ["TestVault"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
"mfa_enabled": True,
|
||||
"mfa_secret": generate_secret(),
|
||||
"mfa_method": "totp",
|
||||
"mfa_recovery_codes": ["hash-fictif"],
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
test_vault_path = os.path.abspath("test-vault")
|
||||
os.chdir(str(tmp))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = test_vault_path
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
os.environ["DRACO_ADMIN_MACHINE_KEY"] = MACHINE_KEY
|
||||
os.environ["DRACO_SSO_MACHINE_KEY"] = "cle-sso-test"
|
||||
os.environ["DRACO_SESSION_SECRET"] = "secret-draco-test"
|
||||
|
||||
from backend import draco_session
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.main import app
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED", "DRACO_ADMIN_MACHINE_KEY",
|
||||
"DRACO_SSO_MACHINE_KEY", "DRACO_SESSION_SECRET"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login(client, username, password):
|
||||
return client.post("/api/auth/login",
|
||||
json={"username": username, "password": password})
|
||||
|
||||
|
||||
def _creer(client, username="nouveau", password="motdepasse-initial", **kw):
|
||||
corps = {"username": username, "password": password,
|
||||
"display_name": kw.pop("display_name", "Nouveau Compte"),
|
||||
"role": kw.pop("role", "user")}
|
||||
corps.update(kw)
|
||||
return client.post(BASE, headers=MACHINE_HEADERS, json=corps)
|
||||
|
||||
|
||||
# ── Protection par clé machine ──────────────────────────────────────────
|
||||
|
||||
class TestProtection:
|
||||
def test_sans_cle_refuse(self, machine_client):
|
||||
assert machine_client.get(BASE).status_code == 401
|
||||
assert machine_client.post(
|
||||
BASE, json={"username": "x1", "password": "motdepasse"}
|
||||
).status_code == 401
|
||||
|
||||
def test_mauvaise_cle_refusee(self, machine_client):
|
||||
resp = machine_client.get(
|
||||
BASE, headers={"X-Draco-Machine-Key": "mauvaise-cle"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_cle_sso_ne_suffit_pas(self, machine_client):
|
||||
"""La clé des révocations (lecture) n'ouvre pas l'administration."""
|
||||
resp = machine_client.get(
|
||||
BASE, headers={"X-Draco-Machine-Key": "cle-sso-test"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_session_admin_ne_suffit_pas(self, machine_client):
|
||||
"""Même une session administratrice ordinaire est refusée."""
|
||||
token = _login(machine_client, "admin", "chab30").json()["access_token"]
|
||||
resp = machine_client.get(
|
||||
BASE, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_non_configuree_inerte(self, machine_client, monkeypatch):
|
||||
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
|
||||
resp = machine_client.get(BASE, headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Lecture ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLecture:
|
||||
def test_liste(self, machine_client):
|
||||
resp = machine_client.get(BASE, headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 2
|
||||
noms = [c["username"] for c in data["comptes"]]
|
||||
assert "admin" in noms and "mfauser" in noms
|
||||
|
||||
def test_profil_etendu_sans_secret(self, machine_client):
|
||||
resp = machine_client.get(f"{BASE}/mfauser", headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
profil = resp.json()
|
||||
assert profil["username"] == "mfauser"
|
||||
assert profil["display_name"] == "Utilisateur MFA"
|
||||
assert profil["language"] == "fr"
|
||||
assert profil["avatar_present"] is False
|
||||
assert profil["role"] == "user"
|
||||
assert profil["active"] is True
|
||||
assert profil["mfa_enabled"] is True
|
||||
# Jamais de secret : ni le haché du mot de passe, ni le TOTP.
|
||||
brut = resp.text
|
||||
assert "password_hash" not in brut
|
||||
assert "mfa_secret" not in brut
|
||||
assert "mfa_recovery" not in brut
|
||||
|
||||
def test_compte_inconnu_404(self, machine_client):
|
||||
resp = machine_client.get(f"{BASE}/personne", headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Création / modification ─────────────────────────────────────────────
|
||||
|
||||
class TestCreationModification:
|
||||
def test_creation_puis_lecture(self, machine_client):
|
||||
resp = _creer(machine_client)
|
||||
assert resp.status_code == 201, resp.text
|
||||
profil = resp.json()
|
||||
assert profil["username"] == "nouveau"
|
||||
assert profil["must_change_password"] is True
|
||||
|
||||
lu = machine_client.get(f"{BASE}/nouveau", headers=MACHINE_HEADERS)
|
||||
assert lu.status_code == 200
|
||||
assert lu.json()["display_name"] == "Nouveau Compte"
|
||||
|
||||
# Le compte créé se connecte, avec changement forcé signalé.
|
||||
login = _login(machine_client, "nouveau", "motdepasse-initial")
|
||||
assert login.status_code == 200
|
||||
assert login.json()["user"]["must_change_password"] is True
|
||||
|
||||
def test_creation_doublon_409(self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
assert _creer(machine_client).status_code == 409
|
||||
assert _creer(machine_client, username="admin").status_code == 409
|
||||
|
||||
def test_modification(self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
resp = machine_client.patch(
|
||||
f"{BASE}/nouveau", headers=MACHINE_HEADERS,
|
||||
json={"display_name": "Nom Modifié", "role": "admin"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["display_name"] == "Nom Modifié"
|
||||
assert resp.json()["role"] == "admin"
|
||||
|
||||
def test_modification_inconnue_404(self, machine_client):
|
||||
resp = machine_client.patch(
|
||||
f"{BASE}/personne", headers=MACHINE_HEADERS,
|
||||
json={"display_name": "X"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Désactivation / réactivation ────────────────────────────────────────
|
||||
|
||||
class TestDesactivation:
|
||||
def test_desactivation_revoque_puis_reactivation(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
login = _login(machine_client, "nouveau", "motdepasse-initial")
|
||||
assert login.status_code == 200
|
||||
token_natif = login.json()["access_token"]
|
||||
|
||||
profil = machine_client.get(
|
||||
f"{BASE}/nouveau", headers=MACHINE_HEADERS).json()
|
||||
token_draco, jti, _exp = draco_session.issue_token(
|
||||
{"id": profil["id"], "username": "nouveau",
|
||||
"display_name": "Nouveau Compte"})
|
||||
assert draco_session.verify_token(token_draco) is not None
|
||||
|
||||
resp = machine_client.post(f"{BASE}/nouveau/desactiver",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is False
|
||||
|
||||
# Connexion refusée, ancienne session native refusée,
|
||||
# jti draco révoqué et publié dans la liste des révocations.
|
||||
assert _login(machine_client, "nouveau",
|
||||
"motdepasse-initial").status_code == 401
|
||||
me = machine_client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {token_natif}"})
|
||||
assert me.status_code == 401
|
||||
assert draco_session.verify_token(token_draco) is None
|
||||
revocations = machine_client.get(
|
||||
"/api/sso/revocations",
|
||||
headers={"X-Draco-Machine-Key": "cle-sso-test"})
|
||||
assert revocations.status_code == 200
|
||||
assert jti in [r["jti"] for r in revocations.json()["revoked"]]
|
||||
|
||||
resp = machine_client.post(f"{BASE}/nouveau/reactiver",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is True
|
||||
|
||||
# Réactivation : nouvelle connexion OK, ancien jti toujours révoqué.
|
||||
assert _login(machine_client, "nouveau",
|
||||
"motdepasse-initial").status_code == 200
|
||||
assert draco_session.verify_token(token_draco) is None
|
||||
|
||||
|
||||
# ── Réinitialisation du mot de passe ────────────────────────────────────
|
||||
|
||||
class TestReinitialisationMotDePasse:
|
||||
def test_ancien_refuse_nouveau_accepte_changement_force(
|
||||
self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/reinitialiser-mot-de-passe",
|
||||
headers=MACHINE_HEADERS,
|
||||
json={"password": "nouveau-mot-de-passe"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["must_change_password"] is True
|
||||
|
||||
assert _login(machine_client, "nouveau",
|
||||
"motdepasse-initial").status_code == 401
|
||||
login = _login(machine_client, "nouveau", "nouveau-mot-de-passe")
|
||||
assert login.status_code == 200
|
||||
assert login.json()["user"]["must_change_password"] is True
|
||||
|
||||
# Le changement effectif par l'utilisateur efface le marqueur.
|
||||
token = login.json()["access_token"]
|
||||
changement = machine_client.post(
|
||||
"/api/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"current_password": "nouveau-mot-de-passe",
|
||||
"new_password": "mot-de-passe-choisi"})
|
||||
assert changement.status_code == 200, changement.text
|
||||
login2 = _login(machine_client, "nouveau", "mot-de-passe-choisi")
|
||||
assert login2.status_code == 200
|
||||
assert login2.json()["user"]["must_change_password"] is False
|
||||
|
||||
|
||||
# ── Réinitialisation du MFA ─────────────────────────────────────────────
|
||||
|
||||
class TestReinitialisationMfa:
|
||||
def test_mfa_reinitialise(self, machine_client):
|
||||
# Avant : la connexion exige le second facteur.
|
||||
login = _login(machine_client, "mfauser", "motdepasse-mfa")
|
||||
assert login.status_code == 200
|
||||
assert login.json().get("mfa_required") is True
|
||||
|
||||
resp = machine_client.post(f"{BASE}/mfauser/reinitialiser-mfa",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mfa_enabled"] is False
|
||||
|
||||
# Après : connexion directe, sans second facteur.
|
||||
login = _login(machine_client, "mfauser", "motdepasse-mfa")
|
||||
assert login.status_code == 200
|
||||
assert "access_token" in login.json()
|
||||
|
||||
|
||||
# ── Suppression ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestSuppression:
|
||||
def test_suppression(self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
resp = machine_client.delete(f"{BASE}/nouveau",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert machine_client.get(
|
||||
f"{BASE}/nouveau", headers=MACHINE_HEADERS).status_code == 404
|
||||
assert _login(machine_client, "nouveau",
|
||||
"motdepasse-initial").status_code == 401
|
||||
|
||||
def test_suppression_inconnue_404(self, machine_client):
|
||||
resp = machine_client.delete(f"{BASE}/personne",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
|
||||
|
||||
def _emettre(client, username, nombre=1):
|
||||
"""Émet ``nombre`` jetons draco pour un compte ; retourne
|
||||
[(jeton, jti, exp), …]."""
|
||||
from backend import draco_session
|
||||
|
||||
profil = client.get(f"{BASE}/{username}",
|
||||
headers=MACHINE_HEADERS).json()
|
||||
return [draco_session.issue_token(
|
||||
{"id": profil["id"], "username": username,
|
||||
"display_name": profil["display_name"]})
|
||||
for _ in range(nombre)]
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_liste_sessions(self, machine_client):
|
||||
from datetime import datetime
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
emis = _emettre(machine_client, "nouveau", 2)
|
||||
|
||||
resp = machine_client.get(f"{BASE}/nouveau/sessions",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "nouveau"
|
||||
assert len(data["sessions"]) == 2
|
||||
jtis = [s["jti"] for s in data["sessions"]]
|
||||
assert jtis == [j for _t, j, _e in emis] # exp croissant
|
||||
for session, (_t, _j, exp) in zip(data["sessions"], emis):
|
||||
assert session["exp"] == exp
|
||||
assert session["revoquee"] is False
|
||||
echeance = datetime.fromisoformat(session["expire_le"])
|
||||
assert echeance.tzinfo is not None
|
||||
assert int(echeance.timestamp()) == exp
|
||||
|
||||
def test_liste_sessions_vide_et_compte_inconnu(self, machine_client):
|
||||
resp = machine_client.get(f"{BASE}/mfauser/sessions",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"username": "mfauser", "sessions": []}
|
||||
resp = machine_client.get(f"{BASE}/personne/sessions",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_revocation_unitaire(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
(t1, jti1, _e1), (t2, _jti2, _e2) = _emettre(
|
||||
machine_client, "nouveau", 2)
|
||||
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/{jti1}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"jti": jti1, "revoquee": True}
|
||||
|
||||
# La session révoquée est morte, l'autre vit ; la liste la
|
||||
# signale comme révoquée.
|
||||
assert draco_session.verify_token(t1) is None
|
||||
assert draco_session.verify_token(t2) is not None
|
||||
sessions = machine_client.get(
|
||||
f"{BASE}/nouveau/sessions", headers=MACHINE_HEADERS).json()
|
||||
etats = {s["jti"]: s["revoquee"] for s in sessions["sessions"]}
|
||||
assert etats[jti1] is True
|
||||
|
||||
def test_revocation_cross_utilisateur_404(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
(token, jti, _e), = _emettre(machine_client, "nouveau", 1)
|
||||
|
||||
# Le jti de « nouveau » demandé sous « mfauser » : 404, et la
|
||||
# session de « nouveau » reste valide (jamais de révocation
|
||||
# cross-utilisateur).
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/mfauser/sessions/{jti}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
assert draco_session.verify_token(token) is not None
|
||||
|
||||
# Jti inconnu et compte inconnu : 404 également.
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/jti-inconnu/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/personne/sessions/{jti}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_revoquer_toutes_sans_desactiver(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
emis = _emettre(machine_client, "nouveau", 2)
|
||||
(autre_token, _j, _e), = _emettre(machine_client, "mfauser", 1)
|
||||
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/revoquer-toutes",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"revoquees": 2}
|
||||
|
||||
for token, _jti, _exp in emis:
|
||||
assert draco_session.verify_token(token) is None
|
||||
# Les sessions des autres comptes ne sont pas touchées.
|
||||
assert draco_session.verify_token(autre_token) is not None
|
||||
|
||||
# Plus rien à révoquer : le registre a été vidé pour ce compte
|
||||
# (avant toute nouvelle connexion, qui émettrait une session).
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/revoquer-toutes",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.json() == {"revoquees": 0}
|
||||
|
||||
# Déconnexion globale ≠ désactivation : le compte reste actif
|
||||
# et une nouvelle connexion fonctionne.
|
||||
profil = machine_client.get(f"{BASE}/nouveau",
|
||||
headers=MACHINE_HEADERS).json()
|
||||
assert profil["active"] is True
|
||||
assert _login(machine_client, "nouveau",
|
||||
"motdepasse-initial").status_code == 200
|
||||
|
||||
def test_sessions_protegees_par_cle_machine(self, machine_client,
|
||||
monkeypatch):
|
||||
assert machine_client.get(
|
||||
f"{BASE}/nouveau/sessions").status_code == 401
|
||||
assert machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/un-jti/revoquer").status_code == 401
|
||||
assert machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/revoquer-toutes").status_code == 401
|
||||
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
|
||||
assert machine_client.get(
|
||||
f"{BASE}/nouveau/sessions",
|
||||
headers=MACHINE_HEADERS).status_code == 404
|
||||
|
||||
def test_sessions_journalisees(self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
(_t, jti, _e), = _emettre(machine_client, "nouveau", 1)
|
||||
machine_client.post(f"{BASE}/nouveau/sessions/{jti}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
machine_client.post(f"{BASE}/nouveau/sessions/revoquer-toutes",
|
||||
headers=MACHINE_HEADERS)
|
||||
|
||||
audit_file = Path("data/audit.log")
|
||||
entrees = [json.loads(ligne)
|
||||
for ligne in audit_file.read_text(
|
||||
encoding="utf-8").splitlines() if ligne.strip()]
|
||||
actions = {(e["action"], e["cible"]) for e in entrees
|
||||
if e.get("cible") == "nouveau"}
|
||||
assert ("session_revoquee", "nouveau") in actions
|
||||
assert ("sessions_revoquees", "nouveau") in actions
|
||||
|
||||
|
||||
# ── Journalisation ──────────────────────────────────────────────────────
|
||||
|
||||
class TestJournalisation:
|
||||
def test_actions_consignees(self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
machine_client.post(f"{BASE}/nouveau/desactiver",
|
||||
headers=MACHINE_HEADERS)
|
||||
|
||||
audit_file = Path("data/audit.log")
|
||||
assert audit_file.exists()
|
||||
entrees = [json.loads(ligne)
|
||||
for ligne in audit_file.read_text(
|
||||
encoding="utf-8").splitlines() if ligne.strip()]
|
||||
actions = {(e["action"], e["cible"]) for e in entrees
|
||||
if e.get("cible") == "nouveau"}
|
||||
assert ("compte_cree", "nouveau") in actions
|
||||
assert ("compte_desactive", "nouveau") in actions
|
||||
for e in entrees:
|
||||
if e.get("cible") == "nouveau":
|
||||
assert e["acteur"] == "hub-test"
|
||||
assert e["timestamp"]
|
||||
@@ -34,13 +34,161 @@ def test_palier_mobile_resserre_v012():
|
||||
|
||||
|
||||
def test_menu_des_applications_par_la_marque():
|
||||
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
|
||||
# (changement d'app en mobile), logo à 24 px.
|
||||
# v0.1.3 : la marque ouvre le menu des applications
|
||||
# (changement d'app en mobile). v0.1.10 : la marque n'est plus
|
||||
# le glyphe ✦ à 24 px mais le logo Spark en img 26 px.
|
||||
# v0.1.11 : la marque n'est plus le logo rouge mais l'étoile
|
||||
# Spark monochrome (brand-spark.png) ; l'onglet agent, lui,
|
||||
# garde le logo rouge.
|
||||
html = _index()
|
||||
assert 'data-act="apps"' in html
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "draco-apps-menu" in js
|
||||
assert "font-size: 24px" in _css()
|
||||
assert '<span class="draco-brand-mark"><img src="/static/img/brand-spark.png"' in html
|
||||
assert ".draco-brand-mark img" in _css()
|
||||
assert "width: 26px" in _css()
|
||||
assert "filter: brightness(0)" in _css()
|
||||
assert '[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }' in _css()
|
||||
assert 'data-icon="agent"><img src="/static/img/logos/spark.png"' in html
|
||||
assert "border: 3px solid var(--agent)" in _css()
|
||||
recherche = (FRONTEND / "recherche.html").read_text(encoding="utf-8")
|
||||
assert '<span class="draco-brand-mark"><img src="/static/img/brand-spark.png"' in recherche
|
||||
|
||||
|
||||
def test_barre_v020_onglet_shaarit_et_garde_data_theme():
|
||||
# Barre v0.2.0 (dracodev-commons @70c837a) : 6e onglet ShaarIt
|
||||
# après Kanbante et avant l'onglet agent, logo fourni tel quel,
|
||||
# et garde data-theme sur les blocs prefers-color-scheme du CSS
|
||||
# vendangé (html:not([data-theme])) — sans elle, un hôte en
|
||||
# data-theme="light" sur système sombre héritait du sombre.
|
||||
css = _css()
|
||||
assert "html:not([data-theme]) .draco-bar {" in css
|
||||
assert "html:not([data-theme]) .draco-bar .draco-brand-mark img" in css
|
||||
assert (FRONTEND / "img" / "logos" / "shaarit.svg").is_file()
|
||||
for nom, jeton in (("index.html", "https://shaarit.dracodev.net/"),
|
||||
("recherche.html", "{{URL_SHAARIT}}")):
|
||||
html = (FRONTEND / nom).read_text(encoding="utf-8")
|
||||
nav = html.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert f'data-app="shaarit" href="{jeton}"' in nav
|
||||
assert 'data-icon="shaarit"><img src="/static/img/logos/shaarit.svg"' in nav
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="shaarit"')
|
||||
assert nav.index('data-app="shaarit"') < nav.index('data-app="agent"')
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
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 (dracodev-commons, HUB-1 / fusion #115) : clé
|
||||
# urls.hub (défaut officiel + dérivation hub-dev), liens du menu
|
||||
# compte (Télécharger, À Propos, Aides, Profil-lien) construits
|
||||
# par le JS vendangé, et branchement hôte : index.html passe la
|
||||
# base Hub de window.DRACO_URLS (backend/draco_config) en
|
||||
# urls.hub de DRACO_BAR_CONFIG.
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert 'hub: "https://hub.dracodev.net/"' in js
|
||||
assert 'hub: "https://hub-dev.dracodev.net/"' in js
|
||||
assert '"telecharger", "Télécharger"' in js
|
||||
assert '"a-propos", "À Propos"' in js
|
||||
assert '"aide", "Aides"' in js
|
||||
assert '"profil", "Profil"' in js
|
||||
assert 'data-hub-link' in js
|
||||
# Profil n'est plus un item relayé ; Déconnexion le reste.
|
||||
assert 'data-compte-act", "profil"' not in js
|
||||
assert 'data-compte-act", "deconnexion"' in js
|
||||
html = _index()
|
||||
assert "window.DRACO_URLS.hub" in html
|
||||
assert "v0.3.0" in html
|
||||
|
||||
|
||||
def test_barre_v060_entree_administration():
|
||||
# Barre v0.6.0 (dracodev-commons, demande de Bruno du
|
||||
# 2026-11-06) : entrée « Administration » du menu compte —
|
||||
# lien Hub <hub>/admin à visibilité conditionnelle. Le JS
|
||||
# vendangé la construit (data-hub-link="admin"), masquée tant
|
||||
# que l'hôte n'affirme pas le statut d'administrateur
|
||||
# (cfg.admin / DRACO_BAR.setAdmin) ; le CSS porte la règle
|
||||
# [hidden] qui la masque malgré le display:flex des items.
|
||||
# Branchement hôte : index.html affirme le statut depuis le
|
||||
# rôle global de /api/auth/me — la définition même de
|
||||
# l'administrateur de suite (garde /admin/* du Hub).
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert 'data-hub-link", "admin"' in js
|
||||
assert '"Administration"' in js
|
||||
assert "setAdmin" in js
|
||||
assert "compteAdmin = cfg.admin === true" in js
|
||||
css = _css()
|
||||
assert ".draco-menu-item[hidden]" in css
|
||||
html = _index()
|
||||
assert 'window.DRACO_BAR.setAdmin(u.role === "admin")' in html
|
||||
# Le fetch d'amorçage d'index.html tourne avant toute
|
||||
# connexion : le branchement qui couvre la connexion par le
|
||||
# formulaire vit dans renderUserSection() (js/auth.js — le
|
||||
# frontend servi est en modules ES ; le app.js monolithique
|
||||
# à la racine n'est plus chargé), appelée par showApp() pour
|
||||
# toutes les sessions établies, au même endroit que la
|
||||
# rangée d'administration native.
|
||||
auth_js = (FRONTEND / "js" / "auth.js").read_text(encoding="utf-8")
|
||||
assert "window.DRACO_BAR.setAdmin(user.role === \"admin\")" in auth_js
|
||||
assert "window.DRACO_BAR.setAdmin(false)" in auth_js
|
||||
|
||||
|
||||
def test_barre_v070_setuser_et_retour_sso_restauration():
|
||||
# Barre v0.7.0 (dracodev-commons) + BUG-123/BUG-124 (chantier
|
||||
# « entrée SSO de bout en bout », écarts constatés par Bruno le
|
||||
# 2026-11-08) :
|
||||
# - BUG-124 : le JS vendangé expose DRACO_BAR.setUser ; l'hôte
|
||||
# le branche dans renderUserSection() (toutes les sessions
|
||||
# établies, comme setAdmin) et dans le fetch d'amorçage
|
||||
# d'index.html — sans quoi le menu compte affichait
|
||||
# « Compte » jusqu'au rechargement après un login SPA.
|
||||
# - BUG-123 : _honorReturnTo signale la redirection (return)
|
||||
# et initAuth() l'invoque sur ses deux chemins de
|
||||
# restauration au démarrage (jeton valide, refresh
|
||||
# silencieux) — sans quoi l'aller-retour SSO ne se refermait
|
||||
# pas quand la session ObsiGate existait déjà.
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "setUser: function (u)" in js
|
||||
assert "v0.7.0" in js
|
||||
html = _index()
|
||||
assert "window.DRACO_BAR.setUser(nom)" in html
|
||||
auth_js = (FRONTEND / "js" / "auth.js").read_text(encoding="utf-8")
|
||||
assert "window.DRACO_BAR.setUser(name || null)" in auth_js
|
||||
assert "window.DRACO_BAR.setUser(null)" in auth_js
|
||||
# BUG-123 : le retour est honoré à la restauration, et
|
||||
# _honorReturnTo rapporte s'il a redirigé.
|
||||
assert "if (this._honorReturnTo()) return true;" in auth_js
|
||||
assert auth_js.count("if (this._honorReturnTo()) return true;") == 2
|
||||
assert "window.location.replace(rt);\n return true;" in auth_js
|
||||
# Le login frais continue de passer par saveToken.
|
||||
assert "this._honorReturnTo();\n }," in auth_js
|
||||
|
||||
|
||||
def test_retour_sso_refresh_force_bug_125():
|
||||
# BUG-125 (suite du chantier « entrée SSO », retest réel de
|
||||
# Bruno le 2026-11-08 après BUG-123) : à l'arrivée d'un
|
||||
# /sso/authorize (return_to présent), le chemin court
|
||||
# d'initAuth (« session valide en sessionStorage ») repartait
|
||||
# sans aucun échange serveur — donc sans réémission du
|
||||
# cookie draco_session par DracoRenewalMiddleware. Cookies
|
||||
# access_token et draco_session absents (sessionStorage
|
||||
# encore « valide ») : le pont de l'application sœur ne
|
||||
# recevait aucun jeton et déposait l'utilisateur devant son
|
||||
# formulaire, sans message (reproduit en navigateur réel
|
||||
# dans la pile « conteneur dev réel », requête du pont
|
||||
# journalisée sans cookie draco). Garde : le chemin court
|
||||
# est conditionné à l'ABSENCE de return_to ; avec return_to,
|
||||
# initAuth passe par le refresh silencieux + /api/auth/me
|
||||
# (réémission du cookie) avant d'honorer le retour — le
|
||||
# nombre d'appels _honorReturnTo des chemins de restauration
|
||||
# reste celui de BUG-123 (2).
|
||||
auth_js = (FRONTEND / "js" / "auth.js").read_text(encoding="utf-8")
|
||||
assert 'get("return_to")' in auth_js
|
||||
assert "&& !retourDemande) {" in auth_js
|
||||
bloc = auth_js.split("async initAuth()", 1)[1]
|
||||
assert bloc.index("!retourDemande") < bloc.index(
|
||||
"await this.refreshAccessToken()")
|
||||
assert auth_js.count("if (this._honorReturnTo()) return true;") == 2
|
||||
|
||||
|
||||
def test_panneau_spark_integre_218():
|
||||
@@ -87,7 +235,8 @@ def test_panneau_console_surimpression_222():
|
||||
assert "draco-console-sur" in panneau
|
||||
assert "assurerSurimpression" in panneau
|
||||
assert "draco-console-styles" in panneau
|
||||
assert 'surCadre.src = urlConsole()' in panneau
|
||||
assert 'var url = urlConsole()' in panneau
|
||||
assert 'surCadre.src = url' in panneau
|
||||
assert 'surEl.style.width = "100%"' in panneau
|
||||
assert 'data-dc="sidebar"' in panneau
|
||||
assert "Mode sidebar" in panneau
|
||||
@@ -104,7 +253,8 @@ def test_csp_ouvre_le_service_d_agents_218():
|
||||
# dérivée de la configuration (backend.draco_config.agent_origine,
|
||||
# testée via la config dans tests/test_security_headers.py).
|
||||
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
|
||||
ligne = next(l for l in main.splitlines() if "connect-src" in l)
|
||||
ligne = next(l for l in main.splitlines()
|
||||
if "connect-src" in l and not l.strip().startswith("#"))
|
||||
assert "agent_origine" in main
|
||||
assert "{agent}" in ligne
|
||||
|
||||
@@ -124,6 +274,16 @@ def test_urls_ecosysteme_via_injection_hote_223():
|
||||
assert "DRACO_URLS[a.dataset.app]" in html
|
||||
|
||||
|
||||
def test_barre_deconnexion_branchee():
|
||||
# BUG-122 (fusion #141) : la barre d'ObsiGate elle-même branche
|
||||
# l'action « deconnexion » sur son point d'entrée de déconnexion
|
||||
# globale (GET /sso/logout, retour à la racine) — avant, le clic
|
||||
# ne faisait rien ici non plus.
|
||||
html = _index()
|
||||
assert 'act === "deconnexion"' in html
|
||||
assert '"/sso/logout?return_to="' in html
|
||||
|
||||
|
||||
def test_rail_mobile_vers_le_vrai_hamburger():
|
||||
html = _index()
|
||||
assert 'matchMedia("(max-width: 768px)")' in html
|
||||
@@ -146,3 +306,56 @@ def test_panneau_version_service_affichee_225():
|
||||
# La ligne naît masquée et n'est démasquée qu'avec une version lue.
|
||||
assert 'class="draco-panneau-version"' in panneau
|
||||
assert "versionEl.hidden = false" in panneau
|
||||
|
||||
|
||||
def test_barre_v040_icones_monoline_menu_compte():
|
||||
# v0.4.0 (HUB-11, fusion #125) : les items du menu compte
|
||||
# portent des icônes SVG monoline construites en DOM, dans le
|
||||
# style des icônes du rail de la console — aucun emoji, aucune
|
||||
# icône pleine. La vendange reste verbatim commons.
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "MENU_ICONES" in js
|
||||
for nom in ("telecharger", "a-propos", "aide", "profil", "deconnexion"):
|
||||
assert nom in js
|
||||
assert "draco-menu-icone" in js
|
||||
assert "stroke: currentColor" not in js # attributs posés en DOM
|
||||
assert 'setAttribute("stroke", "currentColor")' in js
|
||||
assert 'setAttribute("fill", "none")' in js
|
||||
css = _css()
|
||||
assert ".draco-menu-icone" in css
|
||||
bloc = css.split(".draco-menu-icone {", 1)[1].split("}", 1)[0]
|
||||
assert "width: 18px" in bloc
|
||||
assert "var(--ink-2)" in bloc
|
||||
|
||||
|
||||
def test_barre_v041_correctif_icones_espace_de_noms_svg():
|
||||
# v0.4.1 (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 = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
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_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. La vendange reste
|
||||
# verbatim commons ; l'initialisation propre à ObsiGate vit dans
|
||||
# app.js (écouteur draco:theme → applyTheme).
|
||||
js = (FRONTEND / "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 "THEME_CACHE_TTL_MS" in js
|
||||
assert 'CustomEvent("draco:theme"' in js
|
||||
assert "themePref" in js and "themeMode" in js
|
||||
# Aucun nom de Hub figé de plus que les défauts/dérivations
|
||||
# documentés : l'URL du Hub reste la résolue de la barre.
|
||||
assert "resolvedUrls.hub" in js
|
||||
app = (FRONTEND / "app.js").read_text(encoding="utf-8")
|
||||
assert 'addEventListener("draco:theme"' in app
|
||||
assert "barre.themeMode" in app
|
||||
|
||||
@@ -19,7 +19,9 @@ def env_propre(monkeypatch):
|
||||
"""Aucune variable d'URL d'écosystème posée (défauts officiels)."""
|
||||
for nom in (
|
||||
"DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL",
|
||||
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_GITEA_URL",
|
||||
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_SHAARIT_URL",
|
||||
"DRACO_HUB_URL",
|
||||
"DRACO_GITEA_URL",
|
||||
"AGENT_EVENEMENTS_URL",
|
||||
):
|
||||
monkeypatch.delenv(nom, raising=False)
|
||||
@@ -37,6 +39,36 @@ def test_defauts_officiels_sans_dev(env_propre):
|
||||
assert "-dev" not in url
|
||||
|
||||
|
||||
def test_shaarit_defaut_et_derivation(env_propre):
|
||||
"""Barre v0.2.0 : ShaarIt suit le même mécanisme que les autres
|
||||
sœurs — défaut officiel, dérivation -dev depuis l'hôte de l'agent,
|
||||
variable explicite prioritaire."""
|
||||
assert draco_config.app_url("shaarit") == "https://shaarit.dracodev.net"
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
assert draco_config.app_url("shaarit") == "https://shaarit-dev.dracodev.net"
|
||||
assert draco_config.urls_publiques()["shaarit"] == \
|
||||
"https://shaarit-dev.dracodev.net"
|
||||
env_propre.setenv("DRACO_SHAARIT_URL", "https://bm.example.net/")
|
||||
assert draco_config.app_url("shaarit") == "https://bm.example.net"
|
||||
|
||||
|
||||
def test_hub_defaut_et_derivation(env_propre):
|
||||
"""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 depuis l'hôte
|
||||
de l'agent, variable explicite prioritaire ; il est servi dans
|
||||
urls_publiques() (window.DRACO_URLS) pour les liens du menu
|
||||
compte, sans onglet dans la barre."""
|
||||
assert draco_config.app_url("hub") == "https://hub.dracodev.net"
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
assert draco_config.app_url("hub") == "https://hub-dev.dracodev.net"
|
||||
assert draco_config.urls_publiques()["hub"] == \
|
||||
"https://hub-dev.dracodev.net"
|
||||
env_propre.setenv("DRACO_HUB_URL", "https://hub.example.net/")
|
||||
assert draco_config.app_url("hub") == "https://hub.example.net"
|
||||
|
||||
|
||||
def test_agent_derive_des_evenements(env_propre):
|
||||
"""Sans DRACO_AGENT_URL, l'origine suit AGENT_EVENEMENTS_URL —
|
||||
la configuration réelle du poste (dev inclus)."""
|
||||
|
||||
@@ -18,9 +18,13 @@ def _draco_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
|
||||
monkeypatch.setattr(draco_session, "REVOKED_FILE",
|
||||
tmp_path / "draco_revoked.json")
|
||||
monkeypatch.setattr(draco_session, "ISSUED_FILE",
|
||||
tmp_path / "draco_issued.json")
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
yield
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
|
||||
|
||||
# ── Jeton : émission / vérification ─────────────────────────────────────
|
||||
@@ -185,6 +189,44 @@ def test_sso_authorize_liste_blanche(client):
|
||||
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||
|
||||
|
||||
# ── Déconnexion globale navigable (BUG-122, fusion #141) ────────────────
|
||||
|
||||
def test_sso_logout_revoque_et_redirige(admin_client):
|
||||
token = _token_depuis_login(admin_client)
|
||||
assert draco_session.verify_token(token)
|
||||
r = admin_client.get(
|
||||
"/sso/logout",
|
||||
params={"return_to": "https://tiktak.dracodev.net/"},
|
||||
headers={"Cookie": f"draco_session={token}"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "https://tiktak.dracodev.net/"
|
||||
assert draco_session.verify_token(token) is None # jti révoqué
|
||||
cookie = _draco_cookie(r) # cookie effacé
|
||||
assert cookie is not None and "Max-Age=0" in cookie
|
||||
|
||||
|
||||
def test_sso_logout_liste_blanche(admin_client):
|
||||
r = admin_client.get("/sso/logout",
|
||||
params={"return_to": "https://evil.com/phish"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||
r = admin_client.get("/sso/logout", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/" # sans return_to
|
||||
|
||||
|
||||
def test_sso_logout_sans_session_idempotent(client):
|
||||
# Aucun cookie : la route reste une redirection propre (les autres
|
||||
# applications y naviguent même si la session a déjà expiré).
|
||||
r = client.get("/sso/logout",
|
||||
params={"return_to": "https://hub.dracodev.net/"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "https://hub.dracodev.net/"
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
|
||||
|
||||
def test_me_avec_cookie_draco_seul(admin_client):
|
||||
@@ -232,3 +274,58 @@ def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
|
||||
headers={"X-Draco-Machine-Key": "cle-machine"})
|
||||
assert r.status_code == 200
|
||||
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
|
||||
|
||||
|
||||
# ── Registre des sessions (HUB-22, #227) ────────────────────────────────
|
||||
|
||||
AUTRE = {"id": "user-2", "username": "autre", "display_name": "Autre"}
|
||||
|
||||
|
||||
def test_sessions_emises_listees_triees_et_marquees():
|
||||
_t1, jti1, exp1 = draco_session.issue_token(USER)
|
||||
t2, jti2, exp2 = draco_session.issue_token(USER)
|
||||
draco_session.issue_token(AUTRE) # autre sub : exclu
|
||||
draco_session.revoke(jti2, exp2)
|
||||
|
||||
sessions = draco_session.issued_sessions_for_user("admin-1")
|
||||
assert [s["jti"] for s in sessions] == [jti1, jti2] # exp croissant
|
||||
assert sessions[0] == {"jti": jti1, "exp": exp1, "revoquee": False}
|
||||
assert sessions[1] == {"jti": jti2, "exp": exp2, "revoquee": True}
|
||||
assert draco_session.verify_token(t2) is None # cohérent
|
||||
|
||||
|
||||
def test_sessions_emises_expirees_purgees():
|
||||
now = int(time.time())
|
||||
with draco_session._lock:
|
||||
store = draco_session._load_issued()
|
||||
store["admin-1"] = {"ancien": now - 10, "frais": now + 600}
|
||||
assert [s["jti"] for s in
|
||||
draco_session.issued_sessions_for_user("admin-1")] == ["frais"]
|
||||
with draco_session._lock:
|
||||
assert "ancien" not in draco_session._load_issued()["admin-1"]
|
||||
assert draco_session.issued_sessions_for_user("personne") == []
|
||||
assert draco_session.issued_sessions_for_user("") == []
|
||||
|
||||
|
||||
def test_revoquer_une_session_appartenante():
|
||||
token, jti, _exp = draco_session.issue_token(USER)
|
||||
assert draco_session.revoke_one_for_user("admin-1", jti) is True
|
||||
assert draco_session.verify_token(token) is None
|
||||
assert draco_session.is_revoked(jti)
|
||||
|
||||
|
||||
def test_revoquer_une_session_cross_utilisateur_refusee():
|
||||
token, jti, _exp = draco_session.issue_token(USER)
|
||||
# Le jti existe, mais pas dans l'ensemble émis de cet autre sub.
|
||||
assert draco_session.revoke_one_for_user("user-2", jti) is False
|
||||
assert draco_session.verify_token(token) is not None
|
||||
assert draco_session.revoke_one_for_user("admin-1", "inconnu") is False
|
||||
assert draco_session.revoke_one_for_user("", jti) is False
|
||||
|
||||
|
||||
def test_revoquer_une_session_expiree_refusee():
|
||||
now = int(time.time())
|
||||
with draco_session._lock:
|
||||
draco_session._load_issued()["admin-1"] = {"perime": now - 10}
|
||||
assert draco_session.revoke_one_for_user("admin-1", "perime") is False
|
||||
assert not draco_session.is_revoked("perime")
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# tests/test_hub_roles.py
|
||||
# Hub HUB-19 (propagation, #228) — Rôles par application : client du
|
||||
# registre du Hub (parsing, cache 60 s par sub, repli systématique) et
|
||||
# effets sur le chemin draco d'ObsiGate (refus « aucun », garde
|
||||
# administrateur des routes /api/auth/admin/* pour le canal draco).
|
||||
# Contrat posé par l'hôte pilote ShaarIt 0.7.0 (décision D3(a)).
|
||||
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import draco_session, hub_roles
|
||||
|
||||
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
|
||||
USER_SIMPLE = {"id": "user-1", "username": "normaluser", "display_name": "normal"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hub_env(monkeypatch):
|
||||
"""Cache vierge + Hub de test explicite pour chaque test."""
|
||||
hub_roles.reinitialiser_cache()
|
||||
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
|
||||
yield
|
||||
hub_roles.reinitialiser_cache()
|
||||
|
||||
|
||||
def _reponse(role, statut=200, corps=None):
|
||||
"""Fausse réponse urlopen portant un payload du contrat D3(a)."""
|
||||
if corps is None:
|
||||
corps = {"app": "obsigate", "sub": "admin-1", "role": role,
|
||||
"explicite": True, "ttl_secondes": 60}
|
||||
charge = json.dumps(corps).encode("utf-8")
|
||||
|
||||
class _Resp:
|
||||
status = statut
|
||||
|
||||
def read(self):
|
||||
return charge
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _patch_hub(monkeypatch, role, appels):
|
||||
"""Remplace la consultation HTTP par un rôle fixe (compte les appels)."""
|
||||
def _faux(url, cookie):
|
||||
appels.append((url, cookie))
|
||||
return role
|
||||
monkeypatch.setattr(hub_roles, "_consulter_hub", _faux)
|
||||
|
||||
|
||||
# ── Client : parsing du contrat ─────────────────────────────────────────
|
||||
|
||||
def test_consultation_parse_le_contrat(monkeypatch):
|
||||
vu = {}
|
||||
|
||||
def _faux_urlopen(requete, timeout=None):
|
||||
vu["url"] = requete.full_url
|
||||
vu["cookie"] = requete.get_header("Cookie")
|
||||
vu["accept"] = requete.get_header("Accept")
|
||||
vu["timeout"] = timeout
|
||||
return _reponse("administrateur")
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _faux_urlopen)
|
||||
role = hub_roles.role_pour("admin-1", "draco_session=jeton; autre=1")
|
||||
assert role == "administrateur"
|
||||
assert vu["url"] == "https://hub-test.dracodev.net/api/roles/obsigate"
|
||||
assert vu["cookie"] == "draco_session=jeton; autre=1" # Cookie complet relayé
|
||||
assert vu["accept"] == "application/json"
|
||||
assert vu["timeout"] == 2 # délai du contrat
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["aucun", "utilisateur", "administrateur"])
|
||||
def test_consultation_roles_reconnus(monkeypatch, role):
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _reponse(role))
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == role
|
||||
|
||||
|
||||
def test_consultation_role_hors_contrat_inconnu(monkeypatch):
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _reponse("super-admin"))
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_consultation_json_illisible_inconnu(monkeypatch):
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def read(self):
|
||||
return b"pas du json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _Resp())
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_consultation_erreur_http_inconnu(monkeypatch):
|
||||
def _erreur(requete, timeout=None):
|
||||
raise urllib.error.HTTPError(requete.full_url, 500, "boom", {}, io.BytesIO(b""))
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _erreur)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_consultation_hub_injoignable_inconnu(monkeypatch):
|
||||
def _panne(requete, timeout=None):
|
||||
raise urllib.error.URLError("injoignable")
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _panne)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_url_hub_derivee_par_hote_soeur(monkeypatch):
|
||||
# Sans variable explicite : dérivation depuis l'hôte de l'agent
|
||||
# (agent-dev → hub-dev), comme la barre commune (#223).
|
||||
monkeypatch.delenv("DRACO_HUB_URL")
|
||||
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert appels[0][0] == "https://hub-dev.dracodev.net/api/roles/obsigate"
|
||||
|
||||
|
||||
def test_url_hub_defaut_officiel(monkeypatch):
|
||||
monkeypatch.delenv("DRACO_HUB_URL")
|
||||
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
hub_roles.role_pour("admin-1", "c=1")
|
||||
assert appels[0][0] == "https://hub.dracodev.net/api/roles/obsigate"
|
||||
|
||||
|
||||
# ── Client : cache 60 s par sub, horodatage avant l'appel ──────────────
|
||||
|
||||
def test_cache_par_sub_et_ttl(monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert len(appels) == 1 # 2e lecture : cache
|
||||
assert hub_roles.role_pour("user-1", "c=2") == "utilisateur"
|
||||
assert len(appels) == 2 # autre sub : nouvel appel
|
||||
# TTL expiré → nouvel appel.
|
||||
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert len(appels) == 3
|
||||
|
||||
|
||||
def test_echec_conserve_ancien_cache_sans_rafale(monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "administrateur", appels)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
|
||||
assert len(appels) == 1
|
||||
# Le Hub tombe ; le TTL expire : un appel est tenté, échoue, et
|
||||
# l'horodatage posé avant l'appel empêche la rafale — l'ancien
|
||||
# rôle connu n'est ni effacé ni réinterrogé aussitôt.
|
||||
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
|
||||
_patch_hub(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("user-1", "c=2") is None
|
||||
assert len(appels) == 2
|
||||
# Dans la fenêtre qui suit l'échec, l'ancien cache sert toujours.
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_sub_vide_aucun_appel(monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("", "c=1") is None
|
||||
assert appels == []
|
||||
|
||||
|
||||
# ── Intégration : chemin draco (admin_client, secret draco posé) ──────
|
||||
|
||||
@pytest.fixture()
|
||||
def _draco_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
|
||||
monkeypatch.setattr(draco_session, "REVOKED_FILE",
|
||||
tmp_path / "draco_revoked.json")
|
||||
monkeypatch.setattr(draco_session, "ISSUED_FILE",
|
||||
tmp_path / "draco_issued.json")
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
yield
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
|
||||
|
||||
def _pose_cookie_draco(client, user):
|
||||
token, _jti, _exp = draco_session.issue_token(user)
|
||||
client.cookies.set("draco_session", token)
|
||||
return token
|
||||
|
||||
|
||||
def test_draco_aucun_refuse_session_explicite(admin_client, _draco_env, monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "aucun", appels)
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 403
|
||||
assert "niveau « aucun »" in r.json()["detail"]
|
||||
assert "registre des rôles du Hub" in r.json()["detail"]
|
||||
assert appels and appels[0][1].find("draco_session=") >= 0 # Cookie relayé
|
||||
|
||||
|
||||
def test_draco_aucun_mais_connexion_native_intacte(admin_client, _draco_env, monkeypatch):
|
||||
# L'authentification native (émettrice, accès de secours) n'est
|
||||
# jamais touchée par le registre : le login par mot de passe du
|
||||
# même compte passe, même avec un niveau « aucun » au Hub.
|
||||
_patch_hub(monkeypatch, "aucun", [])
|
||||
r = admin_client.post("/api/auth/login",
|
||||
json={"username": "admin", "password": "chab30"})
|
||||
assert r.status_code == 200, r.text
|
||||
# Et la session native ainsi établie ouvre /me (canal natif, le
|
||||
# rôle du Hub ne s'applique pas).
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["username"] == "admin"
|
||||
|
||||
|
||||
def test_draco_role_inconnu_comportement_actuel(admin_client, _draco_env, monkeypatch):
|
||||
_patch_hub(monkeypatch, None, []) # Hub injoignable
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["username"] == "admin"
|
||||
# Canal draco + rôle inconnu : les routes admin gardent les
|
||||
# règles actuelles (le rôle local admin suffit).
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_draco_utilisateur_admin_local_refuse_admin(admin_client, _draco_env, monkeypatch):
|
||||
# Compte local admin authentifié par draco, mais niveau Hub
|
||||
# « utilisateur » : /api/auth/admin/* exige « administrateur ».
|
||||
_patch_hub(monkeypatch, "utilisateur", [])
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text # session draco OK
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert "administrateur" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_draco_administrateur_hub_admin_ok(admin_client, _draco_env, monkeypatch):
|
||||
_patch_hub(monkeypatch, "administrateur", [])
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 200, r.text
|
||||
assert any(u["username"] == "admin" for u in r.json())
|
||||
|
||||
|
||||
def test_draco_utilisateur_simple_admin_refuse_comme_avant(admin_client, _draco_env, monkeypatch):
|
||||
# Compte local non admin, niveau Hub « administrateur » : le
|
||||
# registre n'élève aucun privilège local — refus par la règle
|
||||
# actuelle (rôle local), comme avant HUB-19.
|
||||
_patch_hub(monkeypatch, "administrateur", [])
|
||||
_pose_cookie_draco(admin_client, USER_SIMPLE)
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "Accès admin requis"
|
||||
|
||||
|
||||
def test_natif_admin_sans_draco_jamais_bloque_par_hub(admin_client, _draco_env, monkeypatch):
|
||||
# Session native (login) + niveau Hub « utilisateur » : le canal
|
||||
# n'est pas draco, la garde du registre ne s'applique pas.
|
||||
_patch_hub(monkeypatch, "utilisateur", [])
|
||||
r = admin_client.post("/api/auth/login",
|
||||
json={"username": "admin", "password": "chab30"})
|
||||
assert r.status_code == 200, r.text
|
||||
# Le jeton natif valide prime : get_current_user ne passe pas par
|
||||
# le repli draco, le dict utilisateur n'a ni _draco_sub ni
|
||||
# _hub_role — la garde du registre ne peut pas s'appliquer.
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests de la recherche fédérée ObsiGate (fusion #68).
|
||||
|
||||
Fonctions pures de backend/recherche_cors.py uniquement : aucune base,
|
||||
aucun serveur.
|
||||
"""
|
||||
|
||||
from backend.recherche_cors import (
|
||||
cors_headers_for,
|
||||
is_dev_host,
|
||||
is_prod_host,
|
||||
origin_of,
|
||||
preflight_headers,
|
||||
sister_origins,
|
||||
)
|
||||
|
||||
RESOLVED_DEV = {
|
||||
"obsigate": "https://og-dev.dracodev.net",
|
||||
"flowdeck": "https://flowdeck-dev.dracodev.net",
|
||||
"tiktak": "https://tiktak-dev.dracodev.net",
|
||||
"kanbante": "https://kanbante-dev.dracodev.net",
|
||||
"agent": "https://agent-dev.dracodev.net",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
def test_is_dev_host():
|
||||
assert is_dev_host("og-dev.dracodev.net") is True
|
||||
assert is_dev_host("og.dracodev.net") is False
|
||||
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||
assert is_prod_host("og.dracodev.net") is True
|
||||
assert is_prod_host("og-dev.dracodev.net") is False
|
||||
|
||||
|
||||
def test_origin_of():
|
||||
assert origin_of("https://og-dev.dracodev.net") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("https://og-dev.dracodev.net/chemin") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("{{URL_OBSIGATE}}") is None
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : origines officielles prod ET dev, quelle
|
||||
# que soit la résolution ; ShaarIt ajoutée par la fédération
|
||||
# v0.2.1 (12 origines : 10 sœurs + 2 agent).
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://shaarit.dracodev.net",
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://shaarit-dev.dracodev.net",
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent — l'hôte ne
|
||||
# restreint plus l'allowlist.
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="og-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="og.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="og-dev.dracodev.net"
|
||||
)
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
# Autre environnement autorisé (correctif 2026-10-28).
|
||||
assert cors_headers_for("https://og.dracodev.net",
|
||||
RESOLVED_DEV) is not None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
headers = preflight_headers("https://flowdeck-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests — page /recherche sans texte parasite dans <head>.
|
||||
|
||||
Régression du texte de chantier resté hors commentaire dans
|
||||
``frontend/recherche.html`` : le commentaire de tête se fermait, puis
|
||||
un paragraphe de description du mécanisme de thème suivait en texte
|
||||
brut dans ``<head>`` — le navigateur le rendait tout en haut de la
|
||||
page, au-dessus de la barre header. Le correctif supprime ce texte
|
||||
du rendu (il n'est pas remis en commentaire : un grep doit donner
|
||||
0 occurrence dans les gabarits) sans toucher au mécanisme lui-même,
|
||||
déjà documenté par les commentaires des scripts étape 1 / étape 2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_pas_de_texte_brut_dans_head():
|
||||
"""Entre les balises et les scripts, <head> ne contient aucun
|
||||
texte rendu : tout descriptif vit dans un commentaire ou dans un
|
||||
commentaire de script, jamais en texte brut."""
|
||||
head = _html().split("</head>")[0]
|
||||
sans_commentaires = re.sub(r"<!--.*?-->", "", head, flags=re.DOTALL)
|
||||
sans_scripts = re.sub(
|
||||
r"<script.*?</script>", "", sans_commentaires, flags=re.DOTALL
|
||||
)
|
||||
sans_titre = re.sub(
|
||||
r"<title>.*?</title>", "", sans_scripts, flags=re.DOTALL
|
||||
)
|
||||
texte = re.sub(r"<[^>]+>", "", sans_titre).strip()
|
||||
assert texte == "", f"texte brut rendu dans <head> : {texte[:200]!r}"
|
||||
|
||||
|
||||
def test_mecanisme_theme_intact():
|
||||
"""Le retrait du texte ne touche ni l'étape 1 (script synchrone
|
||||
avant les feuilles) ni l'étape 2 (initThemes différé)."""
|
||||
html = _html()
|
||||
assert html.index("<script>") < html.index('<link rel="stylesheet"')
|
||||
assert "obsigate-theme-mode" in html
|
||||
assert "import('/static/js/themes.js')" in html
|
||||
assert "initThemes" in html
|
||||
assert '<html lang="fr" data-theme="dark">' in html
|
||||
assert '<script src="/static/js/recherche.js"></script>' in html
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests — thème de la page /recherche ObsiGate (défaut 4).
|
||||
|
||||
Mécanisme audité (ne pas réinventer) : le thème ObsiGate n'est PAS une
|
||||
préférence serveur. La SPA le lit dans localStorage au démarrage —
|
||||
``frontend/js/ui.js`` ``initTheme`` (mode seul, pour la première
|
||||
peinture) puis ``frontend/js/sync.js`` ``init`` -> ``themes.js``
|
||||
``initThemes`` (clé + mode, palette complète en variables inline sur
|
||||
``:root``). Clés : ``obsigate-theme`` (clé, ex. ``'spark'``,
|
||||
``'dracula'``, ou historiquement un mode) et ``obsigate-theme-mode``
|
||||
(``'dark'`` | ``'light'`` | ``'high-contrast'`` | ``'sepia'``).
|
||||
|
||||
La page /recherche, servie hors SPA avec un ``data-theme="dark"`` en
|
||||
dur et aucun script de thème, restait donc sombre quel que soit le
|
||||
réglage. Correctif verrouillé ici, en deux étapes dans
|
||||
``frontend/recherche.html`` (aucun fichier partagé touché :
|
||||
``js/recherche.js`` reste identique sur les 4 hôtes, ``draco-barre.css``
|
||||
reste le fichier commun vendangé, ``backend/main.py`` et sa CSP
|
||||
strictes sont inchangés) :
|
||||
|
||||
- étape 1 : script synchrone en tête, avant les feuilles — pose
|
||||
``data-theme`` = mode persisté avant la première peinture ;
|
||||
- étape 2 : module différé — ``initThemes()`` de ``themes.js`` pose la
|
||||
palette complète (16 thèmes, modes générés, thèmes personnalisés).
|
||||
|
||||
``frontend/recherche.css`` décline la barre pour les modes que
|
||||
``draco-barre.css`` ne connaît pas et branche bordures/accents sur
|
||||
``var(--border)`` / ``var(--accent)`` (anciennes valeurs en repli).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FRONTEND = ROOT / "frontend"
|
||||
MODES = ["dark", "light", "high-contrast", "sepia"]
|
||||
NONCE = "TESTNONCE1234567890"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return (FRONTEND / "recherche.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _inline_scripts(html: str) -> list[str]:
|
||||
"""Contenu des scripts inline exécutables (sans src)."""
|
||||
return re.findall(
|
||||
r"<script(?: type=\"module\")?>(.*?)</script>", html, re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def test_etape1_avant_les_feuilles_et_mode_valide():
|
||||
html = _html()
|
||||
scripts = _inline_scripts(html)
|
||||
assert scripts, "aucun script inline dans recherche.html"
|
||||
etape1 = scripts[0]
|
||||
assert "obsigate-theme-mode" in etape1
|
||||
assert "obsigate-theme" in etape1
|
||||
assert "setAttribute('data-theme'" in etape1 or 'setAttribute("data-theme"' in etape1
|
||||
for mode in MODES:
|
||||
assert f"'{mode}'" in etape1, f"mode {mode} absent de l'étape 1"
|
||||
# Le script étape 1 précède la première feuille de style.
|
||||
assert html.index("<script>") < html.index('<link rel="stylesheet"')
|
||||
# Repli sans JS : sombre, le défaut SPA.
|
||||
assert '<html lang="fr" data-theme="dark">' in html
|
||||
|
||||
|
||||
def test_etape1_ignore_les_cles_de_theme():
|
||||
"""Une clé ('dracula', 'spark', …) ne doit jamais devenir un mode."""
|
||||
etape1 = _inline_scripts(_html())[0]
|
||||
# La validation passe par une liste fermée de modes, jamais par la
|
||||
# valeur brute de 'obsigate-theme'.
|
||||
assert "MODES.indexOf(mode)" in etape1
|
||||
assert "MODES.indexOf(legacy)" in etape1
|
||||
assert "'dark'" in etape1 # repli final
|
||||
|
||||
|
||||
def test_etape2_importe_themes_js_avec_garde_historique():
|
||||
html = _html()
|
||||
modules = re.findall(
|
||||
r'<script type="module">(.*?)</script>', html, re.DOTALL
|
||||
)
|
||||
assert len(modules) == 1, "exactement un module inline attendu"
|
||||
etape2 = modules[0]
|
||||
assert "import('/static/js/themes.js')" in etape2
|
||||
assert "initThemes" in etape2
|
||||
# Garde historique : 'obsigate-theme' valant encore un mode sans
|
||||
# 'obsigate-theme-mode' -> on garde le mode de l'étape 1.
|
||||
assert "savedMode === null" in etape2
|
||||
assert "saved === 'dark'" in etape2 and "saved === 'light'" in etape2
|
||||
# Échec de chargement silencieux : la page garde le mode étape 1.
|
||||
assert ".catch(function () {})" in etape2
|
||||
# Le module est après recherche.js (différé : il tourne avant
|
||||
# l'init DOMContentLoaded de recherche.js, sans le modifier).
|
||||
assert html.index("/static/js/recherche.js") < html.index(
|
||||
'<script type="module">'
|
||||
)
|
||||
# themes.js existe bien à l'URL importée et exporte initThemes.
|
||||
themes = (FRONTEND / "js" / "themes.js").read_text(encoding="utf-8")
|
||||
assert "initThemes" in themes
|
||||
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in themes
|
||||
|
||||
|
||||
def test_nonce_csp_pour_les_deux_etapes():
|
||||
"""Comme /recherche injecte window.DRACO_URLS avant </head>, les
|
||||
trois scripts inline doivent tous recevoir le nonce frais."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
html = _html().replace(
|
||||
"</head>",
|
||||
'<script>window.DRACO_URLS = {};</script></head>',
|
||||
1,
|
||||
)
|
||||
out = inject_csp_nonce(html, NONCE)
|
||||
assert not re.findall(r"<script>", out), "script classique sans nonce"
|
||||
assert not re.findall(
|
||||
r'<script type="module">', out
|
||||
), "module inline sans nonce"
|
||||
assert out.count(f'nonce="{NONCE}"') == 3
|
||||
|
||||
|
||||
def test_barre_decline_tous_les_modes():
|
||||
"""draco-barre.css ne connaît que [data-theme="dark"] : les autres
|
||||
modes sont déclinés dans recherche.css (fichier de la page), sans
|
||||
toucher au fichier commun vendangé."""
|
||||
css = _css()
|
||||
barre = (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
|
||||
assert '[data-theme="light"] .draco-bar' not in barre
|
||||
for mode in ("light", "high-contrast", "sepia"):
|
||||
assert f'[data-theme="{mode}"] .draco-bar' in css, mode
|
||||
# Valeurs reprises des générateurs themes.js (_genHighContrast /
|
||||
# _genSepia) : fond noir / sépia, encre blanche / brune.
|
||||
assert "--surface: #0a0a0a" in css and "--ink: #ffffff" in css
|
||||
assert "--surface: #eee3c8" in css and "--ink: #3d2e1a" in css
|
||||
assert "color-scheme: dark" in css and "color-scheme: light" in css
|
||||
|
||||
|
||||
def test_champs_onglets_resultats_suivent_accent_et_bordure():
|
||||
css = _css()
|
||||
assert "border: 1px solid var(--border, #888)" in css
|
||||
assert "background: var(--accent, #3b82f6)" in css
|
||||
assert "border-bottom-color: var(--accent, #3b82f6)" in css
|
||||
assert "outline: 2px solid var(--accent, #3b82f6)" in css
|
||||
|
||||
|
||||
def test_invariants_page_intacts():
|
||||
"""Barre (5e onglet Console Spark), onglets, focus, 'Tout voir',
|
||||
repli typographique : rien ne régresse."""
|
||||
html = _html()
|
||||
for app in ("obsigate", "flowdeck", "tiktak", "kanbante", "agent"):
|
||||
assert f'data-app="{app}"' in html, app
|
||||
assert "{{URL_AGENT}}" in html
|
||||
for tab in ("all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"):
|
||||
assert f'data-rp-tab="{tab}"' in html, tab
|
||||
assert "Tout voir" in html
|
||||
assert 'id="rp-q"' in html and "autofocus" in html
|
||||
assert '<script src="/static/js/recherche.js"></script>' in html
|
||||
# Aucune police externe ajoutée : le repli système imposé par la
|
||||
# CSP stricte de /recherche (font-src 'self' data:) est inchangé.
|
||||
assert "fonts.googleapis.com" not in html
|
||||
assert "fonts.gstatic.com" not in html
|
||||
# Ordre des feuilles inchangé (recherche.css après la barre).
|
||||
assert html.index("draco-barre.css") < html.index("recherche.css")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests — démarrage ObsiGate sans écran de chargement plein écran.
|
||||
|
||||
Le splash (``#boot-splash`` dans ``frontend/index.html`` : icône
|
||||
livre, titre, spinner, « Démarrage… ») masquait tout le reste de la
|
||||
page via ``body:not(.app-booted) > * { display: none !important }``
|
||||
jusqu'au signal de ``frontend/js/app.js`` (ou un repli à 10 s).
|
||||
Les autres applications (TikTak, Kanbante, Flowdeck, console Spark)
|
||||
n'affichent aucun splash de ce type au démarrage : le contenu y
|
||||
apparaît dès qu'il est prêt. Le retrait supprime donc l'écran, son
|
||||
CSS de masquage, son script de pilotage et le signal côté ``app.js``,
|
||||
sans toucher aux indicateurs internes aux vues ni à l'écran de
|
||||
connexion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
MARQUEURS_SPLASH = (
|
||||
"boot-splash",
|
||||
"app-booted",
|
||||
"__OBSIGATE_BOOTED",
|
||||
"__obsigateBootReady",
|
||||
"__setBootStatus",
|
||||
"bs-card",
|
||||
"bs-logo",
|
||||
"bs-title",
|
||||
"bs-spinner",
|
||||
"bs-status",
|
||||
"bs-spin",
|
||||
"Démarrage…",
|
||||
)
|
||||
|
||||
|
||||
def _index() -> str:
|
||||
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _app_js() -> str:
|
||||
return (FRONTEND / "js" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_aucun_marqueur_splash():
|
||||
index = _index()
|
||||
app_js = _app_js()
|
||||
for marqueur in MARQUEURS_SPLASH:
|
||||
assert marqueur not in index, f"{marqueur} encore dans index.html"
|
||||
assert marqueur not in app_js, f"{marqueur} encore dans js/app.js"
|
||||
|
||||
|
||||
def test_pas_de_masquage_global_au_demarrage():
|
||||
"""Aucune règle ne cache tout le <body> en attendant un signal :
|
||||
sans elle, un chargement lent ne peut plus donner un écran blanc
|
||||
bloqué."""
|
||||
index = _index()
|
||||
assert "body:not(.app-booted)" not in index
|
||||
assert "display: none !important" not in index
|
||||
|
||||
|
||||
def test_boot_js_conserve_et_indicateurs_internes_intacts():
|
||||
"""Le démarrage SPA reste branché ; seuls le splash et son signal
|
||||
disparaissent. L'écran de connexion et la barre de progression
|
||||
de recherche (indicateurs internes aux vues) ne sont PAS visés."""
|
||||
app_js = _app_js()
|
||||
assert 'document.addEventListener("DOMContentLoaded"' in app_js
|
||||
assert "await init();" in app_js
|
||||
assert "Sync.init();" in app_js
|
||||
assert "populateVersions" in app_js
|
||||
index = _index()
|
||||
assert 'id="login-screen"' in index
|
||||
assert 'id="search-progress-bar"' in index
|
||||
assert 'id="app"' in index
|
||||
@@ -33,7 +33,7 @@ class TestListAccessibleVaults:
|
||||
|
||||
def test_metadata_fields(self, client):
|
||||
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type"}
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type", "readonly"}
|
||||
assert vault["file_count"] >= 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests des vaults DIR « Spark » : exclusions par vault et lecture seule.
|
||||
|
||||
Couvre le contrat DIR_N_READONLY / DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS :
|
||||
- parsing des trois variables dans load_vault_config() ;
|
||||
- helper chemin_exclu (noms de dossiers et motifs fnmatch à préfixes) ;
|
||||
- navigation filtrée, lectures exclues en 404 (vue, raw, téléchargement),
|
||||
fichier non exclu lisible, indexation et list_paths filtrés ;
|
||||
- mutation refusée en 403 sur un vault déclaré readonly ;
|
||||
- /api/vaults expose readonly=true pour un DIR readonly.
|
||||
|
||||
Le vault de test est un répertoire temporaire monté en DIR (jamais un
|
||||
vault de notes), avec une arborescence qui imite la vue Spark visée :
|
||||
Console/, Workspaces/ (avec un .home), Executeurs/Hermes/{missions,data,.env}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
VAULT_NAME = "SparkTest"
|
||||
|
||||
|
||||
def _build_spark_tree(root: Path) -> None:
|
||||
"""Crée l'arborescence technique de test sous *root*."""
|
||||
(root / "Console").mkdir(parents=True)
|
||||
(root / "Console" / "code.py").write_text('print("visible")\n', encoding="utf-8")
|
||||
(root / "Console" / "notes.md").write_text("# Notes\n", encoding="utf-8")
|
||||
|
||||
atelier = root / "Workspaces" / "atelier"
|
||||
(atelier / ".home").mkdir(parents=True)
|
||||
(atelier / "note.md").write_text("# Atelier\n", encoding="utf-8")
|
||||
(atelier / ".home" / "cache.md").write_text("# Cache du sandbox\n", encoding="utf-8")
|
||||
|
||||
venv = root / ".venv"
|
||||
venv.mkdir()
|
||||
(venv / "lib.py").write_text("# lib du venv\n", encoding="utf-8")
|
||||
|
||||
hermes = root / "Executeurs" / "Hermes"
|
||||
(hermes / "missions").mkdir(parents=True)
|
||||
(hermes / "data").mkdir(parents=True)
|
||||
(hermes / "missions" / "mission.md").write_text("# Mission\n", encoding="utf-8")
|
||||
(hermes / "data" / "config.md").write_text("# Profil de l'exécuteur\n", encoding="utf-8")
|
||||
(hermes / ".env").write_text("CLE_FICTIVE=ne-pas-exposer\n", encoding="utf-8")
|
||||
|
||||
(root / "racine.md").write_text("# Racine\n", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spark_dir(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "SparkDir"
|
||||
root.mkdir()
|
||||
_build_spark_tree(root)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spark_client(spark_dir: Path):
|
||||
"""TestClient avec le répertoire de test monté en vault DIR readonly.
|
||||
|
||||
Même patron que le fixture ``app_with_vault`` de conftest.py, mais en
|
||||
DIR_N_* avec les trois nouvelles variables posées.
|
||||
"""
|
||||
os.environ["DIR_1_NAME"] = VAULT_NAME
|
||||
os.environ["DIR_1_PATH"] = str(spark_dir)
|
||||
os.environ["DIR_1_READONLY"] = "true"
|
||||
os.environ["DIR_1_IGNORED_DIRS"] = ".home,.venv"
|
||||
os.environ["DIR_1_IGNORED_PATHS"] = "Executeurs/*/data,Executeurs/*/.env"
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index
|
||||
from backend.main import app
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Parsing de la configuration ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestParsingConfig:
|
||||
def test_dir_variables_parsees(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DIR_1_NAME", "SparkParse")
|
||||
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
|
||||
monkeypatch.setenv("DIR_1_READONLY", "true")
|
||||
monkeypatch.setenv("DIR_1_IGNORED_DIRS", ".home, .venv ,__pycache__")
|
||||
monkeypatch.setenv("DIR_1_IGNORED_PATHS", "Executeurs/*/data, Executeurs/*/.env")
|
||||
|
||||
from backend.indexer import load_vault_config
|
||||
|
||||
cfg = load_vault_config()["SparkParse"]
|
||||
assert cfg["type"] == "DIR"
|
||||
assert cfg["readonly"] is True
|
||||
assert cfg["ignoredDirs"] == {".home", ".venv", "__pycache__"}
|
||||
assert cfg["ignoredPaths"] == ["Executeurs/*/data", "Executeurs/*/.env"]
|
||||
|
||||
def test_dir_sans_variables_defauts(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DIR_1_NAME", "DirNu")
|
||||
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
|
||||
|
||||
from backend.indexer import load_vault_config
|
||||
|
||||
cfg = load_vault_config()["DirNu"]
|
||||
assert cfg["readonly"] is False
|
||||
assert cfg["ignoredDirs"] == set()
|
||||
assert cfg["ignoredPaths"] == []
|
||||
|
||||
|
||||
# ── Helper chemin_exclu ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCheminExclu:
|
||||
CFG: ClassVar[dict] = {
|
||||
"ignoredDirs": {".home", ".venv"},
|
||||
"ignoredPaths": ["Executeurs/*/data", "Executeurs/*/.env"],
|
||||
}
|
||||
|
||||
def test_nom_de_dossier_a_toute_profondeur(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert chemin_exclu_dans_config(self.CFG, "Workspaces/atelier/.home/cache.md")
|
||||
assert chemin_exclu_dans_config(self.CFG, ".venv/lib.py")
|
||||
assert chemin_exclu_dans_config(self.CFG, ".home")
|
||||
|
||||
def test_motif_chemin_et_sous_arbre(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data/config.md")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Pi/data/x/y.py")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/.env")
|
||||
|
||||
def test_non_exclus_et_tolerance(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert not chemin_exclu_dans_config(self.CFG, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/missions/mission.md")
|
||||
assert not chemin_exclu_dans_config({}, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(None, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(self.CFG, "")
|
||||
|
||||
|
||||
# ── Navigation et lectures via l'API ────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigationEtLecture:
|
||||
def test_browse_racine_cache_les_dossiers_ignores(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}")
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["items"]}
|
||||
assert {"Console", "Workspaces", "Executeurs", "racine.md"} <= names
|
||||
assert ".venv" not in names
|
||||
|
||||
def test_browse_sous_dossier_cache_data(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes"})
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["items"]}
|
||||
assert "missions" in names
|
||||
assert "data" not in names
|
||||
|
||||
def test_browse_chemin_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_vue_fichier_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data/config.md"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_raw_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/data/config.md"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_raw_env_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/.env"})
|
||||
assert resp.status_code == 404
|
||||
assert "CLE_FICTIVE" not in resp.text
|
||||
|
||||
def test_telechargement_env_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/download", params={"path": "Executeurs/Hermes/.env"})
|
||||
assert resp.status_code == 404
|
||||
assert "CLE_FICTIVE" not in resp.text
|
||||
|
||||
def test_fichier_non_exclu_lisible(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Console/code.py"})
|
||||
assert resp.status_code == 200
|
||||
assert "visible" in resp.json()["raw"]
|
||||
|
||||
|
||||
# ── Indexation et liste des chemins ─────────────────────────────────
|
||||
|
||||
|
||||
class TestIndexation:
|
||||
def test_index_ne_contient_aucun_exclu(self, spark_client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(VAULT_NAME)
|
||||
assert data is not None
|
||||
file_paths = {f["path"] for f in data.get("files", [])}
|
||||
assert "Console/code.py" in file_paths
|
||||
for p in file_paths:
|
||||
segments = p.split("/")
|
||||
assert ".home" not in segments and ".venv" not in segments
|
||||
assert "data" not in segments[:-1] or not p.startswith("Executeurs/")
|
||||
path_entries = {e["path"] for e in data.get("paths", [])}
|
||||
assert "Executeurs/Hermes/data" not in path_entries
|
||||
assert "Workspaces/atelier/.home" not in path_entries
|
||||
|
||||
def test_list_paths_filtre_les_exclus(self, spark_client):
|
||||
from backend.services.search import list_paths
|
||||
|
||||
result = list_paths(VAULT_NAME)
|
||||
paths = {r["path"] for r in result["results"]}
|
||||
assert "Console/code.py" in paths
|
||||
assert not any("/data/" in f"/{p}/" and p.startswith("Executeurs/") for p in paths)
|
||||
assert not any(".home" in p.split("/") or ".venv" in p.split("/") for p in paths)
|
||||
|
||||
|
||||
# ── Lecture seule ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLectureSeule:
|
||||
def test_save_refuse_403(self, spark_client):
|
||||
resp = spark_client.put(
|
||||
f"/api/file/{VAULT_NAME}/save",
|
||||
params={"path": "Console/code.py"},
|
||||
json={"content": "print('modifié')\n"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_creation_dossier_refusee_403(self, spark_client):
|
||||
resp = spark_client.post(f"/api/directory/{VAULT_NAME}", json={"path": "NouveauDossier"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_vaults_expose_readonly(self, spark_client):
|
||||
resp = spark_client.get("/api/vaults")
|
||||
assert resp.status_code == 200
|
||||
entry = next(v for v in resp.json() if v["name"] == VAULT_NAME)
|
||||
assert entry["type"] == "DIR"
|
||||
assert entry["readonly"] is True
|
||||