Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39df53e53a | ||
|
|
c2f3feec7e | ||
|
|
3e042eed2d | ||
|
|
183fd316ef | ||
|
|
58e1c23592 | ||
|
|
3e7e53d2fc | ||
|
|
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 | ||
|
|
2a1780b43d | ||
|
|
be05ec99b5 | ||
|
|
3a42bc034d | ||
|
|
27e7664ef0 | ||
|
|
ccbf1d3269 | ||
|
|
0ca14739f9 | ||
|
|
d9192ef355 | ||
|
|
4c9bfd576c | ||
|
|
20898cade7 | ||
|
|
2c12639499 | ||
|
|
7d2b2067ef | ||
|
|
0b52e6ee86 | ||
|
|
cafac69008 | ||
|
|
871875b8e5 | ||
|
|
e5ae3e48d6 | ||
|
|
76de3dd75e | ||
|
|
687427381d | ||
|
|
e418d87802 | ||
|
|
a1f3690085 | ||
|
|
b42265b41e | ||
|
|
0fc7654f6c | ||
|
|
effad97472 | ||
|
|
53d98bfffe | ||
|
|
62aa6079be | ||
|
|
7d48f65ff9 | ||
|
|
3acadf6ad7 | ||
|
|
5492f99f06 | ||
|
|
71221e055b | ||
|
|
86330e8f65 |
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
|
|||||||
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||||
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||||
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
||||||
|
|
||||||
|
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
|
||||||
|
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
|
||||||
|
# OBSIGATE_SECRETS_ENABLED=true
|
||||||
|
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
|
||||||
|
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
|
||||||
|
# OBSIGATE_KEK=<kek-base64-32-octets>
|
||||||
|
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
|
||||||
|
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
|
||||||
|
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
|
||||||
|
|||||||
+33
-1
@@ -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).
|
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
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.80.2**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.81.4**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,38 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.80.2] — 2026-10-27
|
## [2.80.2] — 2026-10-27
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.2).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.80.2 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.81.1 | 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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.2).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.80.2 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
|
||||||
|
|
||||||
|
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
|
||||||
|
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
|
||||||
|
|
||||||
|
Format source (constaté dans ``backend/ai.py`` et
|
||||||
|
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
|
||||||
|
Seules les entrées dont le nom ressemble à un secret sont migrables :
|
||||||
|
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
|
||||||
|
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
|
||||||
|
touchées par la purge.
|
||||||
|
|
||||||
|
Règles de sécurité, non négociables :
|
||||||
|
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
|
||||||
|
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
|
||||||
|
Les aperçus ne contiennent que des noms, des types et ``has_value``.
|
||||||
|
- L'import ne supprime rien : il crée des secrets dans le coffre et
|
||||||
|
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
|
||||||
|
exige ``confirmer=True`` et ne supprime que les noms listés.
|
||||||
|
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
|
||||||
|
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
|
||||||
|
|
||||||
|
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
|
||||||
|
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
|
||||||
|
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
|
||||||
|
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
|
||||||
|
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
|
||||||
|
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
|
||||||
|
deux fois côté interface (import, puis purge).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.coffre_store import (
|
||||||
|
ValidationError,
|
||||||
|
create_coffre,
|
||||||
|
create_secret,
|
||||||
|
get_coffre,
|
||||||
|
get_secret,
|
||||||
|
resolve_data_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.coffre_migration")
|
||||||
|
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
COFFRE_INTERNE_SLUG = "obsigate-interne"
|
||||||
|
COFFRE_INTERNE_NOM = "ObsiGate interne"
|
||||||
|
|
||||||
|
# (nom dans api_keys.json, type de secret, service d'affichage).
|
||||||
|
# Miroir des clés lues par backend/ai.py (clés IA) et par
|
||||||
|
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
|
||||||
|
# est volontairement absente : ce n'est pas un secret.
|
||||||
|
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
|
||||||
|
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
|
||||||
|
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
|
||||||
|
("GEMINI_API_KEY", "api_key", "Gemini"),
|
||||||
|
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
|
||||||
|
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
|
||||||
|
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
|
||||||
|
("MISTRAL_API_KEY", "api_key", "Mistral"),
|
||||||
|
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
|
||||||
|
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
|
||||||
|
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
|
||||||
|
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
|
||||||
|
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
|
||||||
|
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
|
||||||
|
|
||||||
|
|
||||||
|
def _nom_secret(nom_source: str) -> str:
|
||||||
|
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
|
||||||
|
return nom_source.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _api_keys_path(base: Path) -> Path:
|
||||||
|
return base / "api_keys.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_api_keys(base: Path) -> dict:
|
||||||
|
path = _api_keys_path(base)
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_api_keys(base: Path, data: dict) -> None:
|
||||||
|
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
|
||||||
|
path = _api_keys_path(base)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(tmp, path)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def lister_candidats(data_dir: str | Path | None = None) -> list:
|
||||||
|
"""Aperçu de migration : métadonnées seules, jamais de valeur.
|
||||||
|
|
||||||
|
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
|
||||||
|
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
|
||||||
|
non vide est présente dans le fichier) et ``deja_existant`` (un
|
||||||
|
secret de ce nom existe déjà dans le coffre d'instance).
|
||||||
|
"""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
cles = _read_api_keys(base)
|
||||||
|
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
|
||||||
|
candidats = []
|
||||||
|
for source, type_secret, service in CLES_MIGRABLES:
|
||||||
|
nom = _nom_secret(source)
|
||||||
|
valeur = cles.get(source)
|
||||||
|
candidats.append(
|
||||||
|
{
|
||||||
|
"nom": nom,
|
||||||
|
"source": source,
|
||||||
|
"type": type_secret,
|
||||||
|
"service": service,
|
||||||
|
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
|
||||||
|
"deja_existant": (
|
||||||
|
get_secret(coffre["id"], nom, base) is not None
|
||||||
|
if coffre is not None
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return candidats
|
||||||
|
|
||||||
|
|
||||||
|
def importer_api_keys(
|
||||||
|
data_dir: str | Path | None = None, cree_par: str = ""
|
||||||
|
) -> dict:
|
||||||
|
"""Importe les entrées avec valeur vers le coffre d'instance.
|
||||||
|
|
||||||
|
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
|
||||||
|
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
|
||||||
|
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
|
||||||
|
ne peut pas chiffrer (le routeur les traduit en 503).
|
||||||
|
"""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
cles = _read_api_keys(base)
|
||||||
|
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
|
||||||
|
if coffre is None:
|
||||||
|
coffre = create_coffre(
|
||||||
|
slug=COFFRE_INTERNE_SLUG,
|
||||||
|
nom=COFFRE_INTERNE_NOM,
|
||||||
|
proprietaire=(cree_par or "admin").strip(),
|
||||||
|
perimetre="instance",
|
||||||
|
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
|
||||||
|
data_dir=base,
|
||||||
|
)
|
||||||
|
migres: list[str] = []
|
||||||
|
deja_existants: list[str] = []
|
||||||
|
sans_valeur: list[str] = []
|
||||||
|
for source, type_secret, service in CLES_MIGRABLES:
|
||||||
|
nom = _nom_secret(source)
|
||||||
|
valeur = cles.get(source)
|
||||||
|
if not (isinstance(valeur, str) and valeur.strip()):
|
||||||
|
sans_valeur.append(nom)
|
||||||
|
continue
|
||||||
|
if get_secret(coffre["id"], nom, base) is not None:
|
||||||
|
deja_existants.append(nom)
|
||||||
|
continue
|
||||||
|
create_secret(
|
||||||
|
coffre,
|
||||||
|
nom,
|
||||||
|
type_secret,
|
||||||
|
valeur,
|
||||||
|
service=service,
|
||||||
|
note="Migré depuis api_keys.json (Phase 2).",
|
||||||
|
data_dir=base,
|
||||||
|
)
|
||||||
|
migres.append(nom)
|
||||||
|
return {
|
||||||
|
"coffre": COFFRE_INTERNE_SLUG,
|
||||||
|
"migres": migres,
|
||||||
|
"deja_existants": deja_existants,
|
||||||
|
"sans_valeur": sans_valeur,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def purger_api_keys(
|
||||||
|
noms: list[str] | None,
|
||||||
|
data_dir: str | Path | None = None,
|
||||||
|
confirmer: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Supprime du clair les noms listés, après confirmation explicite.
|
||||||
|
|
||||||
|
``confirmer`` doit valoir True, sinon refus (jamais automatique).
|
||||||
|
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
|
||||||
|
l'opération entière, pour ne jamais supprimer une clé par erreur.
|
||||||
|
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
|
||||||
|
"""
|
||||||
|
if not confirmer:
|
||||||
|
raise ValidationError(
|
||||||
|
"Suppression du clair refusée : confirmation explicite requise"
|
||||||
|
)
|
||||||
|
demandes = [(n or "").strip().lower() for n in (noms or [])]
|
||||||
|
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
|
||||||
|
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
|
||||||
|
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
|
||||||
|
inconnus = [
|
||||||
|
n
|
||||||
|
for n in demandes
|
||||||
|
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
|
||||||
|
]
|
||||||
|
if inconnus:
|
||||||
|
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
|
||||||
|
sources_a_supprimer = set()
|
||||||
|
for demande in demandes:
|
||||||
|
for source, _, _ in CLES_MIGRABLES:
|
||||||
|
if demande in (source.lower(), _nom_secret(source)):
|
||||||
|
sources_a_supprimer.add(source)
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
cles = _read_api_keys(base)
|
||||||
|
supprimes: list[str] = []
|
||||||
|
absents: list[str] = []
|
||||||
|
for source in sorted(sources_a_supprimer):
|
||||||
|
if source in cles:
|
||||||
|
del cles[source]
|
||||||
|
supprimes.append(_nom_secret(source))
|
||||||
|
else:
|
||||||
|
absents.append(_nom_secret(source))
|
||||||
|
_write_api_keys(base, cles)
|
||||||
|
return {"supprimes": supprimes, "absents": absents}
|
||||||
|
|
||||||
|
|
||||||
|
def creer_consommateur_avec_octrois(
|
||||||
|
nom: str,
|
||||||
|
octrois_demandes: list[dict[str, Any]] | None,
|
||||||
|
cree_par: str,
|
||||||
|
data_dir: str | Path | None = None,
|
||||||
|
) -> tuple[dict, str, list, list]:
|
||||||
|
"""Enregistre un consommateur et ses octrois en une seule opération.
|
||||||
|
|
||||||
|
Retourne (consommateur public, jeton à usage unique, octrois créés,
|
||||||
|
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
|
||||||
|
n'annule pas le consommateur ni les autres octrois : elle est
|
||||||
|
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
|
||||||
|
lui-même n'est jamais modifié par cette opération.
|
||||||
|
"""
|
||||||
|
from backend.coffre_store import ( # import local : évite un cycle
|
||||||
|
IntrouvableError,
|
||||||
|
create_consommateur,
|
||||||
|
create_octroi,
|
||||||
|
)
|
||||||
|
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
consommateur, jeton = create_consommateur(nom, cree_par, base)
|
||||||
|
crees: list[dict] = []
|
||||||
|
erreurs: list[dict] = []
|
||||||
|
for demande in octrois_demandes or []:
|
||||||
|
slug = str(demande.get("coffre_slug", "") or "")
|
||||||
|
secret_nom = str(demande.get("secret_nom", "") or "") or None
|
||||||
|
try:
|
||||||
|
coffre = get_coffre(slug, base)
|
||||||
|
if coffre is None:
|
||||||
|
raise IntrouvableError(f"Coffre introuvable : {slug}")
|
||||||
|
secret_id = None
|
||||||
|
if secret_nom is not None:
|
||||||
|
secret = get_secret(coffre["id"], secret_nom, base)
|
||||||
|
if secret is None:
|
||||||
|
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
|
||||||
|
secret_id = secret["id"]
|
||||||
|
octroi = create_octroi(
|
||||||
|
coffre=coffre,
|
||||||
|
cible_type="consommateur",
|
||||||
|
cible_id=consommateur["id"],
|
||||||
|
secret_id=secret_id,
|
||||||
|
cree_par=cree_par,
|
||||||
|
expiration=demande.get("expiration"),
|
||||||
|
data_dir=base,
|
||||||
|
)
|
||||||
|
crees.append(octroi)
|
||||||
|
except (IntrouvableError, ValidationError) as exc:
|
||||||
|
erreurs.append(
|
||||||
|
{
|
||||||
|
"coffre_slug": slug,
|
||||||
|
"secret_nom": secret_nom,
|
||||||
|
"erreur": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return consommateur, jeton, crees, erreurs
|
||||||
+86
-2
@@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
# #223 : l'origine du service d'agents n'est pas figée — elle
|
# #223 : l'origine du service d'agents n'est pas figée — elle
|
||||||
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
|
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
|
||||||
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
|
# 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()
|
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"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"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; "
|
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; "
|
"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:; "
|
"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; "
|
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||||
"worker-src 'self' blob:; "
|
"worker-src 'self' blob:; "
|
||||||
f"frame-src 'self' blob: {agent}; "
|
f"frame-src 'self' blob: {agent}; "
|
||||||
@@ -521,6 +531,15 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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
|
# Auth router
|
||||||
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
||||||
from backend.ai_routes import router as ai_router
|
from backend.ai_routes import router as ai_router
|
||||||
@@ -940,6 +959,71 @@ if FRONTEND_DIR.exists():
|
|||||||
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
|
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
|
||||||
raise HTTPException(status_code=404, detail="Admin page not found")
|
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) 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 — sinon le navigateur bloquerait les appels
|
||||||
|
sœurs 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"),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
} - {""}))
|
||||||
|
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}")
|
@app.get("/{full_path:path}")
|
||||||
async def serve_spa(request: Request, full_path: str):
|
async def serve_spa(request: Request, full_path: str):
|
||||||
"""Serve the SPA index.html for all non-API routes."""
|
"""Serve the SPA index.html for all non-API routes."""
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
|||||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||||
|
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
|
||||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""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 : celles de ``backend.draco_config``
|
||||||
|
(``urls_publiques()`` : explicite > dérivation par l'hôte de
|
||||||
|
l'agent > défaut officiel), **plus** les origines prod ou ``-dev``
|
||||||
|
officielles correspondant à l'hôte de la requête — ce second
|
||||||
|
ensemble couvre le cas où la configuration seule ne déclare pas
|
||||||
|
l'environnement courant ;
|
||||||
|
- 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",
|
||||||
|
})
|
||||||
|
|
||||||
|
DEV_ORIGINS = frozenset({
|
||||||
|
"https://og-dev.dracodev.net",
|
||||||
|
"https://flowdeck-dev.dracodev.net",
|
||||||
|
"https://tiktak-dev.dracodev.net",
|
||||||
|
"https://kanbante-dev.dracodev.net",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
|
||||||
|
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if is_dev_host(request_host):
|
||||||
|
origins |= DEV_ORIGINS
|
||||||
|
elif is_prod_host(request_host):
|
||||||
|
origins |= PROD_ORIGINS
|
||||||
|
if not origins:
|
||||||
|
return PROD_ORIGINS | DEV_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)
|
||||||
+127
-2
@@ -19,6 +19,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
|||||||
utilisé ici, volontairement).
|
utilisé ici, volontairement).
|
||||||
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
||||||
qu'il n'est pas activé, toutes les routes répondent 503.
|
qu'il n'est pas activé, toutes les routes répondent 503.
|
||||||
|
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
|
||||||
|
d'instance, en deux temps (import, puis purge confirmée), et
|
||||||
|
enregistrement d'un consommateur avec ses octrois en un appel.
|
||||||
|
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
|
||||||
|
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
|
||||||
|
d'erreur restent ceux de la Phase 1b (voir
|
||||||
|
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
|
||||||
|
l'état ``acces_requis`` côté Spark).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,6 +43,13 @@ from backend.coffre_crypto import (
|
|||||||
KEKAbsenteError,
|
KEKAbsenteError,
|
||||||
generate_kek_b64,
|
generate_kek_b64,
|
||||||
)
|
)
|
||||||
|
from backend.coffre_migration import (
|
||||||
|
COFFRE_INTERNE_SLUG,
|
||||||
|
creer_consommateur_avec_octrois,
|
||||||
|
importer_api_keys,
|
||||||
|
lister_candidats,
|
||||||
|
purger_api_keys,
|
||||||
|
)
|
||||||
from backend.coffre_store import (
|
from backend.coffre_store import (
|
||||||
CoffreExisteError,
|
CoffreExisteError,
|
||||||
IntrouvableError,
|
IntrouvableError,
|
||||||
@@ -273,6 +288,43 @@ async def api_create_consommateur(
|
|||||||
return {**consommateur, "jeton": jeton}
|
return {**consommateur, "jeton": jeton}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/coffres/consommateurs/avec-octrois")
|
||||||
|
async def api_create_consommateur_avec_octrois(
|
||||||
|
body: dict = Body(...), current_user=Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
|
||||||
|
|
||||||
|
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
|
||||||
|
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
|
||||||
|
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
|
||||||
|
sans annuler le consommateur ni les autres octrois.
|
||||||
|
"""
|
||||||
|
_exiger_actives()
|
||||||
|
try:
|
||||||
|
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
|
||||||
|
nom=body.get("nom", ""),
|
||||||
|
octrois_demandes=body.get("octrois") or [],
|
||||||
|
cree_par=_username(current_user),
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
_audit(
|
||||||
|
_username(current_user),
|
||||||
|
"consommateur_cree",
|
||||||
|
consommateur=consommateur["nom"],
|
||||||
|
resultat="ok",
|
||||||
|
)
|
||||||
|
for octroi in octrois:
|
||||||
|
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
|
||||||
|
_audit(
|
||||||
|
_username(current_user),
|
||||||
|
"octroi_cree",
|
||||||
|
coffre=coffre_octroi["slug"] if coffre_octroi else None,
|
||||||
|
resultat="ok",
|
||||||
|
)
|
||||||
|
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
|
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
|
||||||
async def api_revoke_consommateur(
|
async def api_revoke_consommateur(
|
||||||
consommateur_id: str, current_user=Depends(require_admin)
|
consommateur_id: str, current_user=Depends(require_admin)
|
||||||
@@ -291,6 +343,67 @@ async def api_revoke_consommateur(
|
|||||||
return consommateur
|
return consommateur
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
|
||||||
|
# L'import ne supprime rien ; la purge exige une confirmation explicite
|
||||||
|
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
|
||||||
|
# une valeur, seulement des noms et des comptes.
|
||||||
|
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
|
||||||
|
# capturées par le paramètre slug.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/coffres/migration/api-keys")
|
||||||
|
async def api_migration_apercu(current_user=Depends(require_admin)):
|
||||||
|
_exiger_actives()
|
||||||
|
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/coffres/migration/api-keys/import")
|
||||||
|
async def api_migration_import(current_user=Depends(require_admin)):
|
||||||
|
_exiger_actives()
|
||||||
|
try:
|
||||||
|
resultat = importer_api_keys(cree_par=_username(current_user))
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except KEKAbsenteError:
|
||||||
|
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||||
|
except CryptoIndisponibleError:
|
||||||
|
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||||
|
for nom in resultat["migres"]:
|
||||||
|
_audit(
|
||||||
|
_username(current_user),
|
||||||
|
"migration_api_keys",
|
||||||
|
coffre=COFFRE_INTERNE_SLUG,
|
||||||
|
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
|
||||||
|
resultat="ok",
|
||||||
|
)
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/coffres/migration/api-keys/purger")
|
||||||
|
async def api_migration_purger(
|
||||||
|
body: dict = Body(...), current_user=Depends(require_admin)
|
||||||
|
):
|
||||||
|
_exiger_actives()
|
||||||
|
if body.get("confirmer") is not True:
|
||||||
|
raise HTTPException(status_code=400, detail="confirmation_requise")
|
||||||
|
try:
|
||||||
|
resultat = purger_api_keys(
|
||||||
|
noms=body.get("noms") or [], confirmer=True
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
for nom in resultat["supprimes"]:
|
||||||
|
_audit(
|
||||||
|
_username(current_user),
|
||||||
|
"migration_api_keys_purge",
|
||||||
|
coffre=COFFRE_INTERNE_SLUG,
|
||||||
|
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
|
||||||
|
resultat="ok",
|
||||||
|
)
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
|
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -299,8 +412,20 @@ async def api_revoke_consommateur(
|
|||||||
async def api_distribuer(request: Request, body: dict = Body(...)):
|
async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||||
_exiger_actives()
|
_exiger_actives()
|
||||||
reference = (body.get("reference") or "").strip()
|
reference = (body.get("reference") or "").strip()
|
||||||
contexte_type = (body.get("contexte_type") or "").strip()
|
# Phase 3 (Spark) : le contexte peut arriver à plat
|
||||||
contexte_id = (body.get("contexte_id") or "").strip()
|
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
|
||||||
|
# forme envoyée par le client Spark). Les deux formes sont acceptées.
|
||||||
|
contexte_type = str(body.get("contexte_type") or "").strip()
|
||||||
|
contexte_id_raw = body.get("contexte_id")
|
||||||
|
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
|
||||||
|
if not contexte_type or not contexte_id:
|
||||||
|
contexte = body.get("contexte")
|
||||||
|
if isinstance(contexte, dict):
|
||||||
|
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
|
||||||
|
contexte_id_raw = contexte.get("id")
|
||||||
|
contexte_id = contexte_id or (
|
||||||
|
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
|
||||||
|
)
|
||||||
if not contexte_type or not contexte_id:
|
if not contexte_type or not contexte_id:
|
||||||
raise HTTPException(status_code=400, detail="contexte_manquant")
|
raise HTTPException(status_code=400, detail="contexte_manquant")
|
||||||
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.80.2"
|
version = "2.81.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.80.2"
|
version = "2.81.4"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.80.2",
|
"version": "2.81.4",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
|
||||||
|
|
||||||
|
Référence du contrat pour les clients serveur-à-serveur : Spark
|
||||||
|
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
|
||||||
|
duplique aucune logique de chiffrement : le chiffrement reste
|
||||||
|
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
|
||||||
|
|
||||||
|
## 1. Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/coffres/distribuer
|
||||||
|
Authorization: Bearer <jeton-consommateur>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Corps (forme à plat, Phase 1b) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "coffre:<slug>/<nom>",
|
||||||
|
"contexte_type": "mission",
|
||||||
|
"contexte_id": "mission-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "coffre:<slug>/<nom>",
|
||||||
|
"contexte": { "type": "mission", "id": "mission-123" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La forme à plat reste supportée ; si les deux formes sont présentes,
|
||||||
|
la forme à plat est prioritaire. Le contexte est **obligatoire** :
|
||||||
|
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
|
||||||
|
la requête est refusée avant toute authentification utile.
|
||||||
|
|
||||||
|
Réponse en succès (200) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
|
||||||
|
jamais être écrite dans un fichier, un journal, un prompt, une étape
|
||||||
|
d'activité ou un livrable. Le patron imposé à chaque app est
|
||||||
|
**distribuer → utiliser → oublier** ; à la fin de l'opération
|
||||||
|
(fin de mission, fin de synchronisation), l'environnement qui la
|
||||||
|
contenait est détruit et rien ne persiste.
|
||||||
|
|
||||||
|
Prérequis côté ObsiGate : coffres activés
|
||||||
|
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
|
||||||
|
configurée (sinon 503 `kek_absente`).
|
||||||
|
|
||||||
|
## 2. Authentification : consommateur nommé, jeton propre
|
||||||
|
|
||||||
|
- Un consommateur est créé par un admin :
|
||||||
|
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
|
||||||
|
seule opération avec ses octrois :
|
||||||
|
`POST /api/coffres/consommateurs/avec-octrois`
|
||||||
|
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
|
||||||
|
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
|
||||||
|
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
|
||||||
|
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
|
||||||
|
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
|
||||||
|
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
|
||||||
|
- L'`id` du consommateur sert de `jti` révocable :
|
||||||
|
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
|
||||||
|
toutes ses distributions, sans toucher aux autres consommateurs.
|
||||||
|
- Ce jeton est **distinct** du jeton MCP de notes et de la session
|
||||||
|
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).
|
||||||
|
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||||
|
**sans modification du coffre** et sans toucher à ses secrets.
|
||||||
|
|
||||||
|
## 3. Cas Spark (issue #21)
|
||||||
|
|
||||||
|
- Consommateur nommé `spark` (convention, non imposée par le code :
|
||||||
|
l'autorisation porte sur le jeton, pas sur le nom).
|
||||||
|
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
|
||||||
|
de la mission. La forme imbriquée
|
||||||
|
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
|
||||||
|
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
|
||||||
|
correspondance des erreurs) : le LLM ne voit que la référence
|
||||||
|
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
|
||||||
|
octroi, la mission passe en `acces_requis` en nommant l'accès
|
||||||
|
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
|
||||||
|
le service Spark appelle `/distribuer` et injecte la valeur en
|
||||||
|
variable d'environnement de l'exécuteur, jamais dans le prompt.
|
||||||
|
Fin de mission = environnement détruit.
|
||||||
|
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
|
||||||
|
suivante repasse en `acces_requis`. Limite assumée : une mission
|
||||||
|
déjà démarrée conserve son environnement jusqu'à sa fin.
|
||||||
|
|
||||||
|
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
|
||||||
|
|
||||||
|
| HTTP | `detail` | Sens | Correspondance Spark |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
|
||||||
|
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
|
||||||
|
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
|
||||||
|
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
|
||||||
|
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
|
||||||
|
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
|
||||||
|
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
|
||||||
|
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
|
||||||
|
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
|
||||||
|
|
||||||
|
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
|
||||||
|
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
|
||||||
|
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
|
||||||
|
(priorité : ok > révoqué > expiré > absent).
|
||||||
|
|
||||||
|
## 5. Audit
|
||||||
|
|
||||||
|
Chaque distribution est auditée (`distribution_ok` ou
|
||||||
|
`distribution_refusee`, avec référence, consommateur, contexte et
|
||||||
|
code de résultat). **Aucune entrée d'audit ne contient jamais une
|
||||||
|
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
|
||||||
|
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
|
||||||
|
ces champs même s'ils apparaissaient un jour dans une entrée.
|
||||||
|
La copie manuelle à usage unique (admin, via consommateur temporaire
|
||||||
|
créé puis révoqué immédiatement, valeur copiée au presse-papiers
|
||||||
|
sans affichage) est auditée comme une distribution de contexte
|
||||||
|
`copie_manuelle`, avec la trace distincte de ce contexte.
|
||||||
|
|
||||||
|
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
|
||||||
|
|
||||||
|
- **Isolation workspace Flowdeck** : le type de cible `workspace`
|
||||||
|
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
|
||||||
|
que les octrois de type `consommateur`. L'isolation « un workspace
|
||||||
|
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
|
||||||
|
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
|
||||||
|
références de son workspace, avec son propre jeton consommateur).
|
||||||
|
Un contrôle serveur strict (contexte workspace vérifié côté
|
||||||
|
ObsiGate contre un octroi `workspace`) reste à spécifier et à
|
||||||
|
implémenter si Bruno l'exige — il changerait la sémantique
|
||||||
|
d'autorisation de `/distribuer` et doit être coordonné avec les
|
||||||
|
apps clientes avant tout changement.
|
||||||
|
- **Distribution par session** : non implémentée, volontairement.
|
||||||
|
Elle contredirait la règle Phase 1b (distribution réservée aux
|
||||||
|
consommateurs) et exigerait une ré-authentification qui n'existe
|
||||||
|
pas encore dans ObsiGate. La copie manuelle reste le patron
|
||||||
|
supporté (consommateur temporaire, admin seulement).
|
||||||
|
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
|
||||||
|
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
|
||||||
|
sans go. Patron commun : le serveur distribue, utilise, oublie ;
|
||||||
|
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
|
||||||
|
échoue proprement, sans repli sur une ancienne valeur.
|
||||||
|
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
|
||||||
|
toujours branchés sur `api_keys.json` en clair (voir le document
|
||||||
|
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
|
||||||
|
est un travail séparé de la présente Phase 2.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
|
||||||
|
|
||||||
|
Ce document décrit la seule procédure supportée pour sauvegarder et
|
||||||
|
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
|
||||||
|
Il complète la spécification du coffre (§13, §14) ; il ne décrit
|
||||||
|
aucune valeur réelle — les exemples utilisent des valeurs fictives.
|
||||||
|
|
||||||
|
## 1. Ce qui constitue une sauvegarde
|
||||||
|
|
||||||
|
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
| --- | --- |
|
||||||
|
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
|
||||||
|
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
|
||||||
|
|
||||||
|
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
|
||||||
|
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
|
||||||
|
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
|
||||||
|
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
|
||||||
|
serveur, avec les mêmes permissions restrictives que sur l'instance
|
||||||
|
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
|
||||||
|
une note ObsiGate, ni dans un journal.
|
||||||
|
|
||||||
|
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
|
||||||
|
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
|
||||||
|
au même endroit annulerait la protection du chiffrement.
|
||||||
|
|
||||||
|
## 2. Sauvegarder la KEK (copie externe, hors serveur)
|
||||||
|
|
||||||
|
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
|
||||||
|
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
|
||||||
|
activés) et **jamais stockée par ObsiGate**. Au moment de sa
|
||||||
|
génération :
|
||||||
|
|
||||||
|
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
|
||||||
|
sur un support hors ligne, hors du serveur ObsiGate.
|
||||||
|
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
|
||||||
|
jamais dans le même dossier que `data/`, jamais dans un journal.
|
||||||
|
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
|
||||||
|
l'écran qui ne l'affichera plus jamais).
|
||||||
|
|
||||||
|
Variables d'environnement (documentées aussi dans `.env.example`,
|
||||||
|
sans aucune valeur) :
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
|
||||||
|
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
|
||||||
|
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
|
||||||
|
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
|
||||||
|
|
||||||
|
**Aucune autre variable ne doit contenir une valeur de secret du
|
||||||
|
coffre.** En particulier, ne créez jamais de variable du type
|
||||||
|
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
|
||||||
|
la KEK, et elle n'est pas une valeur de secret du coffre.
|
||||||
|
|
||||||
|
## 3. Restauration sur une instance (y compris vierge)
|
||||||
|
|
||||||
|
Prérequis : une instance ObsiGate avec le coffre activé
|
||||||
|
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
|
||||||
|
d'origine.
|
||||||
|
|
||||||
|
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
|
||||||
|
écriture n'a lieu pendant la copie).
|
||||||
|
2. Restaurez les deux fichiers `coffres.json` et
|
||||||
|
`coffres_valeurs.json` dans le répertoire de données cible, en
|
||||||
|
permissions 0600.
|
||||||
|
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
|
||||||
|
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
|
||||||
|
4. Redémarrez l'instance.
|
||||||
|
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
|
||||||
|
- la vue Secrets liste les coffres et les secrets avec
|
||||||
|
`has_value = true` ;
|
||||||
|
- une distribution de test via un consommateur existant (ou un
|
||||||
|
consommateur de test, révoqué ensuite) aboutit — côté serveur,
|
||||||
|
le succès seul est vérifié, la valeur n'est ni affichée ni
|
||||||
|
consignée ;
|
||||||
|
- le journal d'audit contient `distribution_ok` pour cette
|
||||||
|
référence.
|
||||||
|
|
||||||
|
**Test de restauration obligatoire avant la mise en service réelle
|
||||||
|
(issue #20)** : la procédure ci-dessus a été automatisée dans la
|
||||||
|
suite de tests (`tests/test_coffre_secrets.py`, classe
|
||||||
|
`TestRestaurationVierge`) — copie des deux fichiers vers un
|
||||||
|
répertoire vierge, même KEK, distributions fonctionnelles — et doit
|
||||||
|
aussi être rejouée une fois sur une instance réelle par l'opérateur
|
||||||
|
avant de considérer la sauvegarde comme valide.
|
||||||
|
|
||||||
|
## 4. Perte de la KEK : perte d'accès assumée, par construction
|
||||||
|
|
||||||
|
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
|
||||||
|
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
|
||||||
|
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
|
||||||
|
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
|
||||||
|
secret d'application, ni porte dérobée.
|
||||||
|
|
||||||
|
Procédure en cas de perte :
|
||||||
|
|
||||||
|
1. Considérez toutes les valeurs du coffre comme perdues.
|
||||||
|
2. Créez de nouveaux coffres avec une nouvelle KEK.
|
||||||
|
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
|
||||||
|
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
|
||||||
|
cette nouvelle valeur ; révoquez les anciennes valeurs côté
|
||||||
|
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
|
||||||
|
ne sont pas révoquées là-bas.
|
||||||
|
4. Recréez les consommateurs et les octrois (les anciens octrois
|
||||||
|
pointent vers des secrets qui n'existent plus dans les nouveaux
|
||||||
|
coffres).
|
||||||
|
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
|
||||||
|
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
|
||||||
|
comportement attendu, pas un bug.
|
||||||
|
|
||||||
|
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
|
||||||
|
une récupération : elle exige l'ancienne KEK encore valide pour
|
||||||
|
re-chiffrer les DEK, puis le remplacement de la KEK dans
|
||||||
|
l'environnement ou le fichier KEK.
|
||||||
|
|
||||||
|
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
|
||||||
|
|
||||||
|
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
|
||||||
|
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
|
||||||
|
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
|
||||||
|
en deux temps :
|
||||||
|
|
||||||
|
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
|
||||||
|
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
|
||||||
|
n'est pas modifié.
|
||||||
|
2. **Purge** : après confirmation explicite, et uniquement pour les
|
||||||
|
noms migrés, les entrées sont supprimées du clair. Sans
|
||||||
|
confirmation, rien n'est supprimé — jamais automatiquement.
|
||||||
|
|
||||||
|
**Avertissement** : les lecteurs actuels d'ObsiGate
|
||||||
|
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
|
||||||
|
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
|
||||||
|
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
|
||||||
|
leur lecteur n'est pas branché sur une distribution depuis le coffre.
|
||||||
|
Ne purgez que si vous avez vérifié que le consommateur concerné
|
||||||
|
distribue depuis le coffre, ou si vous acceptez cette interruption.
|
||||||
|
Ce branchement des lecteurs internes est un reste à faire explicite
|
||||||
|
(voir §6).
|
||||||
|
|
||||||
|
## 6. Restes à faire (après cette Phase 2)
|
||||||
|
|
||||||
|
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
|
||||||
|
une distribution depuis le coffre d'instance, pour que la purge du
|
||||||
|
clair ne coupe pas l'IA ni les outils.
|
||||||
|
- Rejouer le test de restauration sur une instance réelle (la suite
|
||||||
|
automatisée simule une instance vierge, elle ne remplace pas un
|
||||||
|
essai sur l'instance de production).
|
||||||
|
- Endpoint de distribution par session utilisateur (copie manuelle
|
||||||
|
sans consommateur temporaire) : non ajouté ici, car il contredirait
|
||||||
|
la règle Phase 1b (distribution réservée aux consommateurs) et
|
||||||
|
exigerait une ré-authentification qui n'existe pas encore — décision
|
||||||
|
à prendre avec l'opérateur avant toute implémentation.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.80.2 | **Dernière mise à jour :** 2026-10-27
|
> **Version :** 2.81.4 | **Dernière mise à jour :** 2026-10-28
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
@@ -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,7 +2,11 @@
|
|||||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
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) ;
|
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 ».
|
||||||
Jetons repris du prototype validé Console v7.2.
|
Jetons repris du prototype validé Console v7.2.
|
||||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
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. */
|
||||||
@@ -32,6 +36,7 @@
|
|||||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
/* 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 { 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:hover { background: var(--surface-2); }
|
||||||
|
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||||
|
|
||||||
/* Menu des applications — construit par le JS depuis les onglets */
|
/* Menu des applications — construit par le JS depuis les onglets */
|
||||||
@@ -42,6 +47,29 @@
|
|||||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||||
}
|
}
|
||||||
.draco-apps-menu[hidden] { display: none; }
|
.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: 248px; 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); }
|
||||||
|
.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 {
|
.draco-apps-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: 10px;
|
padding: 8px 10px; border-radius: 10px;
|
||||||
@@ -122,8 +150,10 @@
|
|||||||
}
|
}
|
||||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||||
.draco-avatar {
|
.draco-avatar {
|
||||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||||
background: #2f6fed; color: #fff; font-weight: 700;
|
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) */
|
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||||
@@ -147,8 +177,8 @@
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||||
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
avatar. Le rail, la recherche et les domaines disparaissent :
|
||||||
disparaissent : chaque app garde ses contrôles natifs (barre
|
chaque app garde ses contrôles natifs (barre
|
||||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||||
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||||
@@ -162,5 +192,9 @@
|
|||||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
.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"],
|
||||||
|
/* 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
+460
-42
@@ -1,5 +1,14 @@
|
|||||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
/* 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 »).
|
||||||
*
|
*
|
||||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
* 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
|
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||||
@@ -10,19 +19,205 @@
|
|||||||
* current: "tiktak", // app courante
|
* current: "tiktak", // app courante
|
||||||
* domain: "pro", // domaine actif
|
* domain: "pro", // domaine actif
|
||||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||||
|
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||||
|
* // ou { display_name: "Bruno", username: "bruno" }
|
||||||
|
* urls: { // bases des applications
|
||||||
|
* 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/",
|
||||||
|
* spark: "https://spark.dracodev.net/"
|
||||||
|
* },
|
||||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||||
* } // qu'elle ne connaît pas
|
* } // 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 : ObsiGate, Flowdeck,
|
||||||
|
* TikTak, Kanbante, 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.
|
||||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
* ouvre le menu des applications, construit depuis les onglets, plus
|
||||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
* (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.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
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/",
|
||||||
|
agent: "https://agent.dracodev.net/",
|
||||||
|
spark: "https://spark.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/",
|
||||||
|
agent: "https://agent-dev.dracodev.net/"
|
||||||
|
};
|
||||||
|
|
||||||
|
var APP_LABELS = {
|
||||||
|
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 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();
|
||||||
|
|
||||||
function setCounts(counts) {
|
function setCounts(counts) {
|
||||||
Object.keys(counts || {}).forEach(function (k) {
|
Object.keys(counts || {}).forEach(function (k) {
|
||||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||||
@@ -43,26 +238,110 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||||
* Construit depuis les onglets présents dans la barre : mêmes
|
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||||
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||||
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||||
* choix d'une application (la navigation suit son cours). */
|
* ✦ 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 bar = document.querySelector(".draco-bar");
|
||||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
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() {
|
function setExpanded(btn, open) {
|
||||||
if (!bar || !brand || menu) return;
|
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
menu = document.createElement("div");
|
}
|
||||||
menu.className = "draco-apps-menu";
|
function refreshMenuHrefs(menu) {
|
||||||
menu.hidden = true;
|
if (!menu) return;
|
||||||
menu.setAttribute("role", "menu");
|
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||||
menu.setAttribute("aria-label", "Applications");
|
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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) {
|
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");
|
var a = document.createElement("a");
|
||||||
a.className = "draco-apps-item";
|
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.dataset.app = t.dataset.app;
|
||||||
a.setAttribute("role", "menuitem");
|
a.setAttribute("role", "menuitem");
|
||||||
var ico = t.querySelector(".draco-apptab-ico");
|
var ico = t.querySelector(".draco-apptab-ico");
|
||||||
@@ -84,44 +363,183 @@
|
|||||||
a.appendChild(txt);
|
a.appendChild(txt);
|
||||||
var badge = t.querySelector(".draco-badge");
|
var badge = t.querySelector(".draco-badge");
|
||||||
if (badge) a.appendChild(badge.cloneNode(true));
|
if (badge) a.appendChild(badge.cloneNode(true));
|
||||||
menu.appendChild(a);
|
appsMenu.appendChild(a);
|
||||||
});
|
});
|
||||||
bar.appendChild(menu);
|
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
|
||||||
|
* de l'agent commun (URL agent résolue — explicite > -dev >
|
||||||
|
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
|
||||||
|
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
|
||||||
|
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
|
||||||
|
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 = document.createElement("span");
|
||||||
|
sparkIco.className = "draco-apptab-ico";
|
||||||
|
sparkIco.setAttribute("style", "--app:#c2437e");
|
||||||
|
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) {
|
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
|
||||||
buildMenu();
|
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
|
||||||
if (!menu) return;
|
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
|
||||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
* remplit l'avatar depuis /api/auth/me après chargement). */
|
||||||
menu.hidden = !open;
|
function buildCompteMenu() {
|
||||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
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);
|
||||||
|
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "draco-menu-item";
|
||||||
|
b.setAttribute("data-compte-act", pair[0]);
|
||||||
|
b.setAttribute("role", "menuitem");
|
||||||
|
b.textContent = pair[1];
|
||||||
|
compteMenu.appendChild(b);
|
||||||
|
});
|
||||||
|
bar.appendChild(compteMenu);
|
||||||
|
}
|
||||||
|
function refreshCompteMenu() {
|
||||||
|
if (!compteMenu) return;
|
||||||
|
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||||
|
var name = displayName();
|
||||||
|
if (head) head.textContent = name || "Compte";
|
||||||
|
}
|
||||||
|
function toggleCompteMenu(force) {
|
||||||
|
buildCompteMenu();
|
||||||
|
if (!compteMenu) return;
|
||||||
|
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||||
|
togglePopup(compteMenu, avatarBtn, force);
|
||||||
|
if (!compteMenu.hidden) lastOpener = avatarBtn;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("click", function (ev) {
|
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||||
var el = ev.target.closest("[data-act]");
|
* événement draco:bar (contrat historique des actions relayées). */
|
||||||
if (el && el.dataset.act === "apps") {
|
function relay(act, el, ev) {
|
||||||
ev.preventDefault();
|
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||||
toggleMenu();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||||
toggleMenu(false);
|
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;
|
if (!el) return;
|
||||||
var act = el.dataset.act;
|
var act = el.dataset.act;
|
||||||
if (act === "domaine") setDomain(el.dataset.dom);
|
if (act === "domaine") setDomain(el.dataset.dom);
|
||||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
relay(act, el, ev);
|
||||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", function (ev) {
|
addGlobalListener("keydown", function (ev) {
|
||||||
if (ev.key === "Escape") toggleMenu(false);
|
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. */
|
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
* 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,
|
||||||
|
setUrls: function (urls) {
|
||||||
|
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||||
|
resolvedUrls = resolveUrls();
|
||||||
|
window.DRACO_BAR.urls = resolvedUrls;
|
||||||
|
applyUrls(resolvedUrls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
buildMenu();
|
applyUrls(resolvedUrls);
|
||||||
|
buildAppsMenu();
|
||||||
|
buildCompteMenu();
|
||||||
|
syncAvatar();
|
||||||
|
refreshCompteMenu();
|
||||||
if (cfg.current) setCurrent(cfg.current);
|
if (cfg.current) setCurrent(cfg.current);
|
||||||
if (cfg.domain) setDomain(cfg.domain);
|
if (cfg.domain) setDomain(cfg.domain);
|
||||||
if (cfg.counts) setCounts(cfg.counts);
|
if (cfg.counts) setCounts(cfg.counts);
|
||||||
|
|||||||
+89
-17
@@ -37,16 +37,19 @@
|
|||||||
display: flex; align-items: center; gap: 9px;
|
display: flex; align-items: center; gap: 9px;
|
||||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
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-titres { min-width: 0; flex: 1; }
|
||||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
.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 {
|
.draco-panneau-hd .fermer {
|
||||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
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) */
|
/* Barre des discussions (façon Muse) */
|
||||||
.draco-disc-bar {
|
.draco-disc-bar {
|
||||||
display: flex; align-items: center; gap: 7px;
|
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.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.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-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-ft { padding: 10px 12px 11px; background: var(--p-fond); }
|
||||||
.draco-panneau-form { display: flex; gap: 8px; }
|
/* Puces de suggestions, comme la console : au-dessus du composer. */
|
||||||
.draco-panneau-form input {
|
.draco-puces {
|
||||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
|
||||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
|
||||||
}
|
}
|
||||||
.draco-panneau-form button {
|
.draco-puces button {
|
||||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
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 { 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 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; }
|
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||||
|
|||||||
+332
-23
@@ -40,7 +40,10 @@
|
|||||||
var convKind = null; // 'principale' | 'parallele'
|
var convKind = null; // 'principale' | 'parallele'
|
||||||
var convTitre = "";
|
var convTitre = "";
|
||||||
var etatConnu = false;
|
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 root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
|
var microEl = null, piecesEl = null, fichierEl = null;
|
||||||
var titreEl = null, tiroirEl = null;
|
var titreEl = null, tiroirEl = null;
|
||||||
var versionEl = null, versionConnue = "", versionMaj = 0;
|
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||||
|
|
||||||
@@ -68,11 +71,16 @@
|
|||||||
return correspondance[h] || null;
|
return correspondance[h] || null;
|
||||||
}
|
}
|
||||||
function urlConsole() {
|
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 params = [];
|
||||||
var id = convId || convStockee();
|
var id = convId || convStockee();
|
||||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||||
var app = appHote();
|
var app = appHote();
|
||||||
if (app) params.push("app=" + encodeURIComponent(app));
|
if (app) params.push("app=" + encodeURIComponent(app));
|
||||||
|
params.push("domain=" + encodeURIComponent(domaine()));
|
||||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +155,15 @@
|
|||||||
|
|
||||||
function ouvrirConsole() {
|
function ouvrirConsole() {
|
||||||
assurerSurimpression();
|
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;
|
if (surOuverte) return;
|
||||||
surOuverte = true;
|
surOuverte = true;
|
||||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||||
@@ -175,7 +191,12 @@
|
|||||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||||
surEl.style.width = largeurPanneau() + "px";
|
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(); }
|
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||||
|
|
||||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
/* ── 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-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||||
'<div class="draco-panneau-hd">' +
|
'<div class="draco-panneau-hd">' +
|
||||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||||
' <div><div class="titre">Spark — agent commun</div>' +
|
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
|
||||||
' <div class="soustitre">Service d\'agents dracodev</div></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>' +
|
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||||
'</div>' +
|
'</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">' +
|
'<div class="draco-disc-bar">' +
|
||||||
' <span class="d-titre">Discussion principale</span>' +
|
' <span class="d-titre">Discussion principale</span>' +
|
||||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||||
@@ -266,17 +284,40 @@
|
|||||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="draco-panneau-msgs"></div>' +
|
'<div class="draco-panneau-msgs"></div>' +
|
||||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
'<div class="draco-panneau-ft">' +
|
||||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
' <div class="draco-puces">' +
|
||||||
' <button type="submit">Envoyer</button></form>' +
|
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
|
||||||
' <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>' +
|
' <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 class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
etatEl = root.querySelector(".draco-panneau-etat");
|
etatEl = root.querySelector(".draco-panneau-hd .soustitre");
|
||||||
inputEl = root.querySelector("input");
|
inputEl = root.querySelector(".draco-composer textarea");
|
||||||
sendBtn = root.querySelector("button[type=submit]");
|
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");
|
titreEl = root.querySelector(".d-titre");
|
||||||
tiroirEl = root.querySelector(".draco-discussions");
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
versionEl = root.querySelector(".draco-panneau-version");
|
versionEl = root.querySelector(".draco-panneau-version");
|
||||||
@@ -288,6 +329,42 @@
|
|||||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||||
root.querySelector("form").addEventListener("submit", envoyer);
|
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"));
|
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
surveillerTheme();
|
surveillerTheme();
|
||||||
@@ -326,8 +403,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function majEtat(cls, texte) {
|
function majEtat(cls, texte) {
|
||||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
/* Statut intégré au titre (sous-titre de l'en-tête) : plus de
|
||||||
etatEl.querySelector(".txt").textContent = texte;
|
* 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() {
|
function majTitre() {
|
||||||
titreEl.textContent = convTitre || "Discussion principale";
|
titreEl.textContent = convTitre || "Discussion principale";
|
||||||
@@ -400,9 +482,21 @@
|
|||||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
.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() {
|
function demarrerDiscussions() {
|
||||||
chargerListeDiscussions()
|
chargerListeDiscussions()
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
|
domaineCharge = domaine();
|
||||||
var precedente = convStockee();
|
var precedente = convStockee();
|
||||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||||
@@ -413,6 +507,7 @@
|
|||||||
chargerHistorique();
|
chargerHistorique();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
|
domaineCharge = null; // échec : la prochaine ouverture réessaiera
|
||||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -780,19 +875,232 @@
|
|||||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function envoyer(ev) {
|
/* ── Composer façon console ────────────────────────────────
|
||||||
ev.preventDefault();
|
* Mêmes règles que la console : + (images/documents, 4 max),
|
||||||
var texte = inputEl.value.trim();
|
* textarea auto-extensible, micro affiché seulement sans texte
|
||||||
if (!texte) return;
|
* 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 = "";
|
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;
|
sendBtn.disabled = true;
|
||||||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
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", {
|
fetch(API + "/api/v1/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
body: JSON.stringify(corps)
|
||||||
})
|
})
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (r.status === 401) {
|
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.";
|
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||||
majEtat("err", "Service d'agents injoignable");
|
majEtat("err", "Service d'agents injoignable");
|
||||||
})
|
})
|
||||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
.finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function ouvrir() {
|
function ouvrir() {
|
||||||
if (!root) { construire(); demarrerDiscussions(); }
|
if (!root) { construire(); demarrerDiscussions(); }
|
||||||
|
else resynchroniserDomaine();
|
||||||
root.classList.add("open");
|
root.classList.add("open");
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
verifierIdentite();
|
verifierIdentite();
|
||||||
|
|||||||
+19
-9
@@ -76,6 +76,7 @@
|
|||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
<link rel="stylesheet" href="/static/draco-barre.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" href="/static/draco-panneau.css" />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
@@ -332,10 +333,14 @@
|
|||||||
|
|
||||||
<!-- Main Application (wrapped for show/hide) -->
|
<!-- Main Application (wrapped for show/hide) -->
|
||||||
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
||||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
|
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
|
||||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
|
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
|
||||||
évolution passe par une nouvelle version de commons.
|
évolution passe par une nouvelle version de commons.
|
||||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
|
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
|
||||||
|
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">
|
<header class="draco-bar" data-current="obsigate">
|
||||||
<div class="draco-bar-zone draco-bar-gauche">
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||||
@@ -361,6 +366,10 @@
|
|||||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="agent" href="https://agent.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="draco-bar-zone draco-bar-centre">
|
<div class="draco-bar-zone draco-bar-centre">
|
||||||
@@ -386,8 +395,7 @@
|
|||||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
|
||||||
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||||
@@ -395,8 +403,9 @@
|
|||||||
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
||||||
Phase 1 : navigation entre apps et domaine mémorisé
|
Phase 1 : navigation entre apps et domaine mémorisé
|
||||||
localement ; la recherche focus la recherche native
|
localement ; la recherche focus la recherche native
|
||||||
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
|
d'ObsiGate ; Alertes unifiées : panneau draco-alertes.js
|
||||||
fédération / l'agent commun (phases suivantes). */
|
(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 ;
|
/* #223 : les href des onglets portent les défauts officiels ;
|
||||||
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
|
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
|
||||||
backend depuis sa configuration) quand il est présent. */
|
backend depuis sa configuration) quand il est présent. */
|
||||||
@@ -431,9 +440,9 @@
|
|||||||
if (b) b.click();
|
if (b) b.click();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
|
if (act === "palette") { location.href = "/recherche"; return; }
|
||||||
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); 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") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
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 (act === "spark") {
|
||||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||||
@@ -6065,6 +6074,7 @@ curl -X POST https://votre-serveur.com/webhook \
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/static/draco-alertes.js"></script>
|
||||||
<script defer src="/static/draco-barre.js"></script>
|
<script defer src="/static/draco-barre.js"></script>
|
||||||
<script defer src="/static/draco-liens.js"></script>
|
<script defer src="/static/draco-liens.js"></script>
|
||||||
<script defer src="/static/draco-panneau.js"></script>
|
<script defer src="/static/draco-panneau.js"></script>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||||
* d'endpoint de distribution par session utilisateur côté backend.
|
* d'endpoint de distribution par session utilisateur côté backend.
|
||||||
|
* - 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
|
||||||
|
* transite jamais par cette vue : l'aperçu et les résultats ne
|
||||||
|
* contiennent que des noms et des comptes (has_value).
|
||||||
* - La vue est rendue directement dans #content-area : elle ne passe ni
|
* - La vue est rendue directement dans #content-area : elle ne passe ni
|
||||||
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
||||||
* ni par la recherche, ni par le graphe.
|
* ni par la recherche, ni par le graphe.
|
||||||
@@ -865,6 +870,83 @@ async function copieManuelle(s) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
||||||
|
* 1. Aperçu (métadonnées seules, jamais de valeur) puis import vers le
|
||||||
|
* coffre d'instance. L'import ne supprime rien dans api_keys.json.
|
||||||
|
* 2. Purge du clair, uniquement après confirmation explicite, et
|
||||||
|
* uniquement pour les noms effectivement migrés. L'avertissement est
|
||||||
|
* affiché avant l'import : les lecteurs actuels (IA, outils) lisent
|
||||||
|
* encore api_keys.json, la purge les prive donc de la clé tant que
|
||||||
|
* leur lecteur n'est pas branché sur le coffre (voir la documentation
|
||||||
|
* de sauvegarde/restauration). */
|
||||||
|
async function importerExistant() {
|
||||||
|
if (!isAdmin()) return;
|
||||||
|
let apercu;
|
||||||
|
try {
|
||||||
|
apercu = await api('/api/coffres/migration/api-keys');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidats = apercu.candidats || [];
|
||||||
|
const lignes = candidats.map((c) => {
|
||||||
|
const etat = c.deja_existant
|
||||||
|
? t('coffres.import_already_exists')
|
||||||
|
: (c.has_value ? t('coffres.import_has_value') : t('coffres.import_no_value'));
|
||||||
|
return `${c.nom} (${c.service}) — ${etat}`;
|
||||||
|
});
|
||||||
|
const body = el('div', {}, [
|
||||||
|
text('p', t('coffres.import_warning'), 'modal-hint'),
|
||||||
|
...lignes.map((ligne) => text('p', ligne, '')),
|
||||||
|
]);
|
||||||
|
const m = openModal(t('coffres.import_title'), body, [
|
||||||
|
modalBtn(t('common.cancel'), '', () => m.close()),
|
||||||
|
modalBtn(t('coffres.import_existing'), 'primary', async () => {
|
||||||
|
let resultat;
|
||||||
|
try {
|
||||||
|
resultat = await api('/api/coffres/migration/api-keys/import', { method: 'POST', body: '{}' });
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m.close();
|
||||||
|
const migres = resultat.migres || [];
|
||||||
|
const resume = [
|
||||||
|
`${t('coffres.import_done')} — ${t('coffres.import_migrated')}: ${migres.length}, ${t('coffres.import_already_exists')}: ${(resultat.deja_existants || []).length}, ${t('coffres.import_no_value')}: ${(resultat.sans_valeur || []).length}`,
|
||||||
|
...migres.map((nom) => `${nom} — ${t('coffres.import_migrated')}`),
|
||||||
|
];
|
||||||
|
const rb = el('div', {}, resume.map((ligne) => text('p', ligne, '')));
|
||||||
|
const boutons = [modalBtn(t('common.close'), 'primary', async () => {
|
||||||
|
rm.close();
|
||||||
|
await openCoffresView('obsigate-interne');
|
||||||
|
})];
|
||||||
|
if (migres.length) {
|
||||||
|
boutons.unshift(modalBtn(t('coffres.import_purge'), 'danger', async () => {
|
||||||
|
const ok = await showConfirm({
|
||||||
|
title: t('coffres.import_purge'),
|
||||||
|
message: `${t('coffres.import_purge_confirm')} ${migres.join(', ')}`,
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const purge = await api('/api/coffres/migration/api-keys/purger', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ confirmer: true, noms: migres }),
|
||||||
|
});
|
||||||
|
showToast(`${t('coffres.import_purged')} (${(purge.supprimes || []).length})`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
}
|
||||||
|
rm.close();
|
||||||
|
await openCoffresView('obsigate-interne');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const rm = openModal(t('coffres.import_title'), rb, boutons);
|
||||||
|
void rm;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function openKekModal(mode) {
|
function openKekModal(mode) {
|
||||||
if (!isAdmin()) return;
|
if (!isAdmin()) return;
|
||||||
if (mode === 'generer') {
|
if (mode === 'generer') {
|
||||||
@@ -1002,6 +1084,12 @@ export async function openCoffresView(slug, options = {}) {
|
|||||||
});
|
});
|
||||||
toolbar.appendChild(search);
|
toolbar.appendChild(search);
|
||||||
toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug)));
|
toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug)));
|
||||||
|
// Phase 2 : migration api_keys.json -> coffre d'instance (admin seulement).
|
||||||
|
// Le bouton est visible quel que soit le coffre affiché : la cible est
|
||||||
|
// toujours le coffre d'instance « ObsiGate interne ».
|
||||||
|
if (isAdmin()) {
|
||||||
|
toolbar.appendChild(modalBtn(t('coffres.import_existing'), '', () => importerExistant()));
|
||||||
|
}
|
||||||
toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => {
|
toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => {
|
||||||
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
|
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||||
|
*
|
||||||
|
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
|
||||||
|
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
|
||||||
|
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
||||||
|
*
|
||||||
|
* Contrat d'API (audité, rien d'inventé) :
|
||||||
|
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||||
|
* -> { results: [{ vault, path, title, snippet }] }
|
||||||
|
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||||
|
* - Flowdeck : GET {base}/api/search?q=
|
||||||
|
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
|
||||||
|
* collections: [{ title, subtitle, url: "/db/<id>" }] }
|
||||||
|
* - TikTak : GET {base}/api/search?q=
|
||||||
|
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||||
|
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
||||||
|
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
|
||||||
|
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
|
||||||
|
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
|
||||||
|
* adressable — même limite que la page /search native de Kanbante).
|
||||||
|
*
|
||||||
|
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||||
|
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||||
|
* hôte de la requête > dérivation > défaut officiel) ;
|
||||||
|
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
|
||||||
|
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
|
||||||
|
* 4. href des onglets .draco-apptab rendus ;
|
||||||
|
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
|
||||||
|
*
|
||||||
|
* Authentification cross-origin : les appels sœurs partent avec
|
||||||
|
* credentials:"include". Le navigateur envoie alors les cookies de
|
||||||
|
* chaque origine, dont le cookie SSO partagé draco_session
|
||||||
|
* (Domain=.dracodev.net), accepté par les 4 API de recherche
|
||||||
|
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
|
||||||
|
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
|
||||||
|
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||||
|
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||||
|
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
/* globalThis : le fichier est chargé comme script classique dans les
|
||||||
|
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
|
||||||
|
* et importé en ESM par le test ObsiGate (frontend/js est "type":
|
||||||
|
* "module") — où `this` de niveau supérieur vaut undefined. */
|
||||||
|
|
||||||
|
var PROD = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
};
|
||||||
|
var DEV = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||||
|
};
|
||||||
|
var APPS = [
|
||||||
|
{ key: "obsigate", label: "ObsiGate" },
|
||||||
|
{ key: "flowdeck", label: "Flowdeck" },
|
||||||
|
{ key: "tiktak", label: "TikTak" },
|
||||||
|
{ key: "kanbante", label: "Kanbante" },
|
||||||
|
];
|
||||||
|
var COLUMN_LABELS = {
|
||||||
|
backlog: "Backlog",
|
||||||
|
in_progress: "En cours",
|
||||||
|
qa: "QA",
|
||||||
|
review: "Relecture",
|
||||||
|
todo: "À faire",
|
||||||
|
done: "Terminé",
|
||||||
|
};
|
||||||
|
var TIMEOUT_MS = 8000;
|
||||||
|
var DEBOUNCE_MS = 250;
|
||||||
|
var PER_APP_LIMIT = 8;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u) return null;
|
||||||
|
u = String(u).trim();
|
||||||
|
if (!u || u.indexOf("{{") !== -1) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) === "/" ? u : u + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
|
||||||
|
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
|
||||||
|
function resolveBases(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var dev = isDevHost(opts.host);
|
||||||
|
var out = {};
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var key = app.key;
|
||||||
|
var u = cleanUrl(opts.explicit && opts.explicit[key]);
|
||||||
|
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
|
||||||
|
if (!u && dev) u = DEV[key];
|
||||||
|
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
|
||||||
|
if (!u) u = PROD[key];
|
||||||
|
out[key] = withSlash(u);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchPath(app, q) {
|
||||||
|
if (app === "obsigate") {
|
||||||
|
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||||
|
}
|
||||||
|
return "api/search?q=" + encodeURIComponent(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snippetOf(s, n) {
|
||||||
|
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
||||||
|
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
||||||
|
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
|
||||||
|
function normalize(app, data, base, q) {
|
||||||
|
data = data || {};
|
||||||
|
if (app === "obsigate") {
|
||||||
|
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||||
|
return {
|
||||||
|
title: r.title || r.path || "Sans titre",
|
||||||
|
snippet: snippetOf(r.snippet || ""),
|
||||||
|
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
|
||||||
|
url:
|
||||||
|
base +
|
||||||
|
"#file=" +
|
||||||
|
encodeURIComponent(r.vault || "") +
|
||||||
|
":" +
|
||||||
|
encodeURIComponent(r.path || ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app === "flowdeck") {
|
||||||
|
var items = (data.pages || []).concat(data.collections || []);
|
||||||
|
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||||
|
var rel = String(r.url || "").replace(/^\//, "");
|
||||||
|
return {
|
||||||
|
title: r.title || "Sans titre",
|
||||||
|
snippet: snippetOf(r.excerpt || ""),
|
||||||
|
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
|
||||||
|
url: base + rel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app === "tiktak") {
|
||||||
|
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
|
||||||
|
var ctx = t.status === "done" ? "Terminée" : "Active";
|
||||||
|
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
||||||
|
return {
|
||||||
|
title: t.title || "Sans titre",
|
||||||
|
snippet: snippetOf(t.description || ""),
|
||||||
|
context: "Tâche · " + ctx,
|
||||||
|
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app === "kanbante") {
|
||||||
|
var rows = [];
|
||||||
|
(data.groups || []).forEach(function (g) {
|
||||||
|
(g.rows || []).forEach(function (row) {
|
||||||
|
rows.push({ group: g.full_name, row: row });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||||
|
var issue = x.row.issue || {};
|
||||||
|
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||||
|
return {
|
||||||
|
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
|
||||||
|
snippet: snippetOf(issue.body || ""),
|
||||||
|
context: x.group + (col ? " · " + col : ""),
|
||||||
|
url: base + "board/" + x.group,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||||
|
function nativeSearchUrl(app, base, q) {
|
||||||
|
if (app === "tiktak" || app === "kanbante") {
|
||||||
|
return base + "search?q=" + encodeURIComponent(q || "");
|
||||||
|
}
|
||||||
|
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
|
||||||
|
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSearchLabel(app) {
|
||||||
|
if (app === "tiktak" || app === "kanbante") {
|
||||||
|
return "Ouvrir la recherche dans " + appLabel(app);
|
||||||
|
}
|
||||||
|
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function appLabel(key) {
|
||||||
|
for (var i = 0; i < APPS.length; i++) {
|
||||||
|
if (APPS[i].key === key) return APPS[i].label;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = {
|
||||||
|
esc: esc,
|
||||||
|
isDevHost: isDevHost,
|
||||||
|
resolveBases: resolveBases,
|
||||||
|
searchPath: searchPath,
|
||||||
|
normalize: normalize,
|
||||||
|
nativeSearchUrl: nativeSearchUrl,
|
||||||
|
PROD: PROD,
|
||||||
|
DEV: DEV,
|
||||||
|
};
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
|
global.RecherchePartout = api;
|
||||||
|
|
||||||
|
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
function fetchSection(app, url, onDone) {
|
||||||
|
var ctrl = null;
|
||||||
|
var timer = null;
|
||||||
|
try {
|
||||||
|
ctrl = new AbortController();
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
ctrl.abort();
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
} catch (e) {
|
||||||
|
ctrl = null;
|
||||||
|
}
|
||||||
|
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
|
||||||
|
if (ctrl) opts.signal = ctrl.signal;
|
||||||
|
fetch(url, opts)
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
var err = new Error("HTTP " + resp.status);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
onDone(null, data);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
onDone(err, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(section, state) {
|
||||||
|
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||||
|
var body = section.querySelector(".rp-body");
|
||||||
|
var count = section.querySelector(".rp-count");
|
||||||
|
var toutVoir = section.querySelector(".rp-all");
|
||||||
|
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
|
||||||
|
toutVoir.textContent = "Tout voir";
|
||||||
|
toutVoir.title = nativeSearchLabel(state.app);
|
||||||
|
if (state.status === "loading") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML =
|
||||||
|
'<p class="rp-state rp-error">' +
|
||||||
|
esc(state.message) +
|
||||||
|
' — <a href="' +
|
||||||
|
esc(nativeSearchUrl(state.app, state.base, state.q)) +
|
||||||
|
'">' +
|
||||||
|
esc(nativeSearchLabel(state.app)) +
|
||||||
|
"</a></p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status === "empty") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML =
|
||||||
|
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
count.textContent = String(state.items.length);
|
||||||
|
body.innerHTML = state.items
|
||||||
|
.map(function (item) {
|
||||||
|
return (
|
||||||
|
'<a class="rp-item" data-rp-link href="' +
|
||||||
|
esc(item.url) +
|
||||||
|
'">' +
|
||||||
|
'<span class="rp-title">' +
|
||||||
|
esc(item.title) +
|
||||||
|
"</span>" +
|
||||||
|
(item.context
|
||||||
|
? '<span class="rp-context">' + esc(item.context) + "</span>"
|
||||||
|
: "") +
|
||||||
|
(item.snippet
|
||||||
|
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||||
|
: "") +
|
||||||
|
"</a>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var root = document.getElementById("recherche");
|
||||||
|
if (!root) return;
|
||||||
|
var input = document.getElementById("rp-q");
|
||||||
|
if (!input) return;
|
||||||
|
var hostApp = root.getAttribute("data-host") || "";
|
||||||
|
var explicit = {};
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var v = root.getAttribute("data-base-" + app.key);
|
||||||
|
if (v) explicit[app.key] = v;
|
||||||
|
});
|
||||||
|
var barUrls = {};
|
||||||
|
try {
|
||||||
|
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
|
||||||
|
} catch (e) {}
|
||||||
|
var tabHrefs = {};
|
||||||
|
try {
|
||||||
|
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
|
||||||
|
var href = tab.getAttribute("href");
|
||||||
|
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
var bases = resolveBases({
|
||||||
|
host: global.location.hostname,
|
||||||
|
explicit: explicit,
|
||||||
|
barUrls: barUrls,
|
||||||
|
tabHrefs: tabHrefs,
|
||||||
|
});
|
||||||
|
var sections = {};
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||||
|
if (el) sections[app.key] = el;
|
||||||
|
});
|
||||||
|
|
||||||
|
var currentQ = "";
|
||||||
|
var runId = 0;
|
||||||
|
var debounceTimer = null;
|
||||||
|
|
||||||
|
function runSearch(q) {
|
||||||
|
currentQ = q;
|
||||||
|
var myRun = ++runId;
|
||||||
|
if (!q) {
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var sec = sections[app.key];
|
||||||
|
if (!sec) return;
|
||||||
|
sec.querySelector(".rp-body").innerHTML =
|
||||||
|
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||||
|
sec.querySelector(".rp-count").textContent = "";
|
||||||
|
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
APPS.forEach(function (app) {
|
||||||
|
var sec = sections[app.key];
|
||||||
|
if (!sec) return;
|
||||||
|
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
|
||||||
|
var url;
|
||||||
|
if (app.key === hostApp) {
|
||||||
|
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||||
|
url = "/" + searchPath(app.key, q);
|
||||||
|
} else {
|
||||||
|
url = bases[app.key] + searchPath(app.key, q);
|
||||||
|
}
|
||||||
|
fetchSection(app.key, url, function (err, data) {
|
||||||
|
if (myRun !== runId) return;
|
||||||
|
if (err) {
|
||||||
|
var msg;
|
||||||
|
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
|
||||||
|
else if (err.status === 401 || err.status === 403)
|
||||||
|
msg = "Connexion requise sur " + app.label;
|
||||||
|
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
||||||
|
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
||||||
|
renderSection(sec, {
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
app: app.key,
|
||||||
|
base: bases[app.key],
|
||||||
|
q: q,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = normalize(app.key, data, bases[app.key], q);
|
||||||
|
renderSection(sec, {
|
||||||
|
status: items.length ? "ok" : "empty",
|
||||||
|
items: items,
|
||||||
|
app: app.key,
|
||||||
|
base: bases[app.key],
|
||||||
|
q: q,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch(q, immediate) {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
if (immediate) {
|
||||||
|
runSearch(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(function () {
|
||||||
|
runSearch(q);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl(q) {
|
||||||
|
try {
|
||||||
|
var url = new URL(global.location.href);
|
||||||
|
if (q) url.searchParams.set("q", q);
|
||||||
|
else url.searchParams.delete("q");
|
||||||
|
global.history.replaceState(null, "", url.toString());
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
syncUrl(q);
|
||||||
|
scheduleSearch(q, false);
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
var first = document.querySelector("[data-rp-link]");
|
||||||
|
if (first) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.click();
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
input.value = "";
|
||||||
|
syncUrl("");
|
||||||
|
scheduleSearch("", true);
|
||||||
|
input.focus();
|
||||||
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
function (e) {
|
||||||
|
var target = e.target;
|
||||||
|
var typing =
|
||||||
|
target &&
|
||||||
|
(target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable);
|
||||||
|
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||||
|
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
function moveFocus(delta, e) {
|
||||||
|
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||||
|
if (!links.length) return;
|
||||||
|
var active = document.activeElement;
|
||||||
|
var idx = links.indexOf(active);
|
||||||
|
var next;
|
||||||
|
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
|
||||||
|
else next = (idx + delta + links.length) % links.length;
|
||||||
|
e.preventDefault();
|
||||||
|
links[next].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = new URLSearchParams(global.location.search);
|
||||||
|
var initialQ = (params.get("q") || "").trim();
|
||||||
|
if (initialQ) {
|
||||||
|
input.value = initialQ;
|
||||||
|
runSearch(initialQ);
|
||||||
|
} else {
|
||||||
|
runSearch("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})(
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window
|
||||||
|
: typeof globalThis !== "undefined"
|
||||||
|
? globalThis
|
||||||
|
: this
|
||||||
|
);
|
||||||
@@ -59,11 +59,11 @@ function _sharedIcon(size = 16) {
|
|||||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
// 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() {
|
function _secretsIcon() {
|
||||||
const s = document.createElement("span");
|
const s = document.createElement("span");
|
||||||
s.className = "coffres-icon-lock";
|
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;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -653,7 +653,7 @@ function applyTheme(themeKey, mode) {
|
|||||||
root.style.setProperty(key, vars[key]);
|
root.style.setProperty(key, vars[key]);
|
||||||
});
|
});
|
||||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
|
||||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||||
|
|||||||
@@ -2757,5 +2757,16 @@
|
|||||||
"coffres.type": "Type",
|
"coffres.type": "Type",
|
||||||
"coffres.value": "Value",
|
"coffres.value": "Value",
|
||||||
"coffres.value_required": "A value is required.",
|
"coffres.value_required": "A value is required.",
|
||||||
"coffres.version": "Version"
|
"coffres.version": "Version",
|
||||||
|
"coffres.import_existing": "Import existing",
|
||||||
|
"coffres.import_title": "Import existing keys",
|
||||||
|
"coffres.import_has_value": "Value present",
|
||||||
|
"coffres.import_no_value": "No value",
|
||||||
|
"coffres.import_already_exists": "Already in the vault",
|
||||||
|
"coffres.import_migrated": "Migrated",
|
||||||
|
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
|
||||||
|
"coffres.import_done": "Import finished",
|
||||||
|
"coffres.import_purge": "Delete plaintext",
|
||||||
|
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
|
||||||
|
"coffres.import_purged": "Plaintext deleted"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2757,5 +2757,16 @@
|
|||||||
"coffres.type": "Type",
|
"coffres.type": "Type",
|
||||||
"coffres.value": "Valeur",
|
"coffres.value": "Valeur",
|
||||||
"coffres.value_required": "Une valeur est requise.",
|
"coffres.value_required": "Une valeur est requise.",
|
||||||
"coffres.version": "Version"
|
"coffres.version": "Version",
|
||||||
|
"coffres.import_existing": "Importer l'existant",
|
||||||
|
"coffres.import_title": "Importer les clés existantes",
|
||||||
|
"coffres.import_has_value": "Valeur présente",
|
||||||
|
"coffres.import_no_value": "Aucune valeur",
|
||||||
|
"coffres.import_already_exists": "Déjà dans le coffre",
|
||||||
|
"coffres.import_migrated": "Migré",
|
||||||
|
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
|
||||||
|
"coffres.import_done": "Import terminé",
|
||||||
|
"coffres.import_purge": "Supprimer le clair",
|
||||||
|
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
|
||||||
|
"coffres.import_purged": "Clair supprimé"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||||
|
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||||
|
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||||
|
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||||
|
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||||
|
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||||
|
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||||
|
.rp-input {
|
||||||
|
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||||
|
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||||
|
.rp-section { margin-bottom: 28px; }
|
||||||
|
.rp-head {
|
||||||
|
display: flex; align-items: baseline; gap: 10px;
|
||||||
|
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||||
|
.rp-count {
|
||||||
|
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||||
|
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||||
|
}
|
||||||
|
.rp-count:empty { display: none; }
|
||||||
|
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||||
|
.rp-state { opacity: .75; margin: 8px 0; }
|
||||||
|
.rp-error { opacity: 1; }
|
||||||
|
.rp-item {
|
||||||
|
display: block; padding: 8px 10px; border-radius: 8px;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||||
|
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||||
|
.rp-title { display: block; font-weight: 600; }
|
||||||
|
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||||
|
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Rechercher partout — ObsiGate</title>
|
||||||
|
<link rel="stylesheet" href="/static/recherche.css">
|
||||||
|
<style>
|
||||||
|
/* Navigation minimale (la barre complète vit dans index.html, la SPA). */
|
||||||
|
.rp-nav { display: flex; gap: 16px; padding: 12px 16px; font-size: .9rem; }
|
||||||
|
.rp-nav a { color: inherit; opacity: .8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 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. -->
|
||||||
|
<nav class="rp-nav">
|
||||||
|
<a href="{{URL_OBSIGATE}}">ObsiGate</a>
|
||||||
|
<a href="{{URL_FLOWDECK}}">Flowdeck</a>
|
||||||
|
<a href="{{URL_TIKTAK}}">TikTak</a>
|
||||||
|
<a href="{{URL_KANBANTE}}">Kanbante</a>
|
||||||
|
</nav>
|
||||||
|
<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}}">
|
||||||
|
<h1>Rechercher partout</h1>
|
||||||
|
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
|
||||||
|
<div class="rp-form">
|
||||||
|
<input id="rp-q" class="rp-input" type="search" name="q" value=""
|
||||||
|
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
<script src="/static/js/recherche.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+18
-3
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
min-width: 400px;
|
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
|
||||||
max-width: 600px;
|
sans max-height ni scroll interne, un formulaire long dépassait le
|
||||||
|
viewport. La modale est désormais bornée au viewport, en colonne
|
||||||
|
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
|
||||||
|
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
|
||||||
|
min-width: min(400px, calc(100vw - 32px));
|
||||||
|
max-width: min(600px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100dvh - 32px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
transition: transform 200ms ease;
|
transition: transform 200ms ease;
|
||||||
}
|
}
|
||||||
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsigate-modal-title {
|
.obsigate-modal-title {
|
||||||
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
|
|||||||
|
|
||||||
.obsigate-modal-body {
|
.obsigate-modal-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsigate-modal-footer {
|
.obsigate-modal-footer {
|
||||||
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
@@ -14358,7 +14372,8 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
|||||||
.coffres-alerts { margin: 12px 0; }
|
.coffres-alerts { margin: 12px 0; }
|
||||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 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-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-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; }
|
.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
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.80.2",
|
"version": "2.81.4",
|
||||||
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -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,62 @@
|
|||||||
|
#!/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(
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -455,3 +456,225 @@ class TestDistribution:
|
|||||||
assert "secret_cree" in actions
|
assert "secret_cree" in actions
|
||||||
assert "distribution_ok" in actions
|
assert "distribution_ok" in actions
|
||||||
assert "valeur" not in json.dumps(resp.json())
|
assert "valeur" not in json.dumps(resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 — Migration api_keys.json (issue #20)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMigrationApiKeys:
|
||||||
|
def _ecrire_api_keys(self, tmp_path):
|
||||||
|
(tmp_path / "api_keys.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"DEEPSEEK_API_KEY": VALEUR_TEST,
|
||||||
|
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
|
||||||
|
"OBSIGATE_GITEA_URL": "https://example.invalid",
|
||||||
|
"MISTRAL_API_KEY": "",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apercu_import_purge(self, client, env_coffre):
|
||||||
|
self._ecrire_api_keys(env_coffre)
|
||||||
|
resp = client.get("/api/coffres/migration/api-keys")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# Aperçu = métadonnées seules, jamais une valeur.
|
||||||
|
assert VALEUR_TEST not in resp.text
|
||||||
|
assert VALEUR_TEST_2 not in resp.text
|
||||||
|
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
|
||||||
|
assert candidats["deepseek_api_key"]["has_value"] is True
|
||||||
|
assert candidats["mistral_api_key"]["has_value"] is False
|
||||||
|
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
|
||||||
|
# Import : ne supprime rien dans api_keys.json.
|
||||||
|
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
resultat = resp.json()
|
||||||
|
assert resultat["coffre"] == "obsigate-interne"
|
||||||
|
assert "deepseek_api_key" in resultat["migres"]
|
||||||
|
assert "obsigate_gitea_token" in resultat["migres"]
|
||||||
|
assert "mistral_api_key" in resultat["sans_valeur"]
|
||||||
|
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||||
|
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
|
||||||
|
# Les secrets migrés sont visibles en métadonnées et distribuables.
|
||||||
|
resp = client.get("/api/coffres/obsigate-interne/secrets")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert VALEUR_TEST not in resp.text
|
||||||
|
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
|
||||||
|
_, jeton, _ = _creer_consommateur_octroi(
|
||||||
|
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||||
|
)
|
||||||
|
resp = _distribuer(
|
||||||
|
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST
|
||||||
|
# Purge sans confirmation : refusée, fichier intact.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/migration/api-keys/purger",
|
||||||
|
json={"confirmer": False, "noms": ["deepseek_api_key"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||||
|
assert "DEEPSEEK_API_KEY" in cles
|
||||||
|
# Purge confirmée : seules les clés listées disparaissent.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/migration/api-keys/purger",
|
||||||
|
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert set(resp.json()["supprimes"]) == {
|
||||||
|
"deepseek_api_key",
|
||||||
|
"obsigate_gitea_token",
|
||||||
|
}
|
||||||
|
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||||
|
assert "DEEPSEEK_API_KEY" not in cles
|
||||||
|
assert "OBSIGATE_GITEA_TOKEN" not in cles
|
||||||
|
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
|
||||||
|
# Audit sans valeur.
|
||||||
|
_fichiers_sans_valeur(env_coffre)
|
||||||
|
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||||
|
assert "migration_api_keys" in audit
|
||||||
|
assert "migration_api_keys_purge" in audit
|
||||||
|
|
||||||
|
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
|
||||||
|
self._ecrire_api_keys(env_coffre)
|
||||||
|
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
|
||||||
|
(env_coffre / "api_keys.json").write_text(
|
||||||
|
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "deepseek_api_key" in resp.json()["deja_existants"]
|
||||||
|
_, jeton, _ = _creer_consommateur_octroi(
|
||||||
|
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||||
|
)
|
||||||
|
resp = _distribuer(
|
||||||
|
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||||
|
)
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
|
||||||
|
|
||||||
|
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
|
||||||
|
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
|
||||||
|
# l'import réussit trivialement même sans KEK (mode dégradé).
|
||||||
|
self._ecrire_api_keys(env_coffre)
|
||||||
|
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||||
|
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert resp.json()["detail"] == "kek_absente"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 — Restauration sur instance vierge (issue #20)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRestaurationVierge:
|
||||||
|
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
|
||||||
|
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||||
|
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||||
|
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
|
||||||
|
# Simulation d'une instance vierge : seuls les deux fichiers sont
|
||||||
|
# copiés, la KEK (variable d'environnement) est inchangée.
|
||||||
|
restaure = tmp_path / "restaure"
|
||||||
|
restaure.mkdir()
|
||||||
|
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||||
|
shutil.copy(env_coffre / nom, restaure / nom)
|
||||||
|
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||||
|
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
|
||||||
|
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||||
|
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
|
||||||
|
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||||
|
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||||
|
restaure = tmp_path / "restaure"
|
||||||
|
restaure.mkdir()
|
||||||
|
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||||
|
shutil.copy(env_coffre / nom, restaure / nom)
|
||||||
|
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
|
||||||
|
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
|
||||||
|
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||||
|
with pytest.raises(TagInvalideError):
|
||||||
|
distribuer_valeur(coffre_r, secret, restaure)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3 — Contexte Spark imbriqué (issue #21)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDistributionSpark:
|
||||||
|
def test_contexte_nested_mission(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/distribuer",
|
||||||
|
json={
|
||||||
|
"reference": "coffre:personnel/gitea-token",
|
||||||
|
"contexte": {"type": "mission", "id": "mission-test-2"},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {jeton}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST
|
||||||
|
|
||||||
|
def test_contexte_nested_sans_octroi_acces_requis(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||||
|
jeton = resp.json()["jeton"]
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/distribuer",
|
||||||
|
json={
|
||||||
|
"reference": "coffre:personnel/gitea-token",
|
||||||
|
"contexte": {"type": "mission", "id": "mission-test-3"},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {jeton}"},
|
||||||
|
)
|
||||||
|
# Côté Spark, ce code se traduit par l'état acces_requis.
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.json()["detail"] == "octroi_absent"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 4 — Consommateur + octrois en un appel (issue #22)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConsommateurAvecOctrois:
|
||||||
|
def test_avec_octrois_distribue(self, client, env_coffre):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/consommateurs/avec-octrois",
|
||||||
|
json={
|
||||||
|
"nom": "tiktak",
|
||||||
|
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
corps = resp.json()
|
||||||
|
assert corps["nom"] == "tiktak"
|
||||||
|
assert corps["jeton"].startswith("csk_")
|
||||||
|
assert len(corps["octrois"]) == 1
|
||||||
|
assert corps["erreurs"] == []
|
||||||
|
resp = _distribuer(client, corps["jeton"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST
|
||||||
|
liste = client.get("/api/coffres/consommateurs").json()
|
||||||
|
assert corps["jeton"] not in json.dumps(liste)
|
||||||
|
assert "jeton_hash" not in json.dumps(liste)
|
||||||
|
|
||||||
|
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/consommateurs/avec-octrois",
|
||||||
|
json={
|
||||||
|
"nom": "flowdeck",
|
||||||
|
"octrois": [
|
||||||
|
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
|
||||||
|
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert len(resp.json()["octrois"]) == 1
|
||||||
|
assert len(resp.json()["erreurs"]) == 1
|
||||||
|
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""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():
|
||||||
|
assert sister_origins(RESOLVED_DEV) == {
|
||||||
|
"https://og-dev.dracodev.net",
|
||||||
|
"https://flowdeck-dev.dracodev.net",
|
||||||
|
"https://tiktak-dev.dracodev.net",
|
||||||
|
"https://kanbante-dev.dracodev.net",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sister_origins_ajoute_env_de_la_requete():
|
||||||
|
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||||
|
{}, request_host="og-dev.dracodev.net"
|
||||||
|
)
|
||||||
|
assert "https://og-dev.dracodev.net" not in sister_origins(
|
||||||
|
{}, request_host="og.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.dracodev.net", RESOLVED_DEV) is 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
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Validates:
|
Validates:
|
||||||
- Theme JSON structure validity
|
- Theme JSON structure validity
|
||||||
- All 15 built-in themes have required keys and valid CSS colors
|
- All 16 built-in themes have required keys and valid CSS colors
|
||||||
- High-contrast and sepia mode generators produce valid CSS vars
|
- High-contrast and sepia mode generators produce valid CSS vars
|
||||||
- Import/export round-trip
|
- Import/export round-trip
|
||||||
- AVAILABLE_MODES includes all 4 modes
|
- AVAILABLE_MODES includes all 4 modes
|
||||||
@@ -80,9 +80,9 @@ class TestThemeStructure:
|
|||||||
assert '_genSepia' in self.content
|
assert '_genSepia' in self.content
|
||||||
|
|
||||||
def test_theme_count(self):
|
def test_theme_count(self):
|
||||||
"""Should have 15 built-in themes."""
|
"""Should have 16 built-in themes."""
|
||||||
names = _extract_theme_names(self.content)
|
names = _extract_theme_names(self.content)
|
||||||
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
|
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
|
||||||
|
|
||||||
def test_required_export_functions(self):
|
def test_required_export_functions(self):
|
||||||
"""All required functions should be exported."""
|
"""All required functions should be exported."""
|
||||||
|
|||||||
Reference in New Issue
Block a user