From 1065ffb0174800b654b28bff5f5d8f30e28b0414 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:10:47 -0400 Subject: [PATCH 1/7] Fusion #69 : Alertes unifiees (app/templates/_draco_bar.html) --- app/templates/_draco_bar.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html index 5f181e6..0220792 100644 --- a/app/templates/_draco_bar.html +++ b/app/templates/_draco_bar.html @@ -6,6 +6,7 @@ v7.82.0 (dev/prod) : les href des onglets ne sont plus figés — l'hôte les injecte depuis sa config (draco_urls(), noms officiels par défaut, noms en -dev déclarés dans le .env de l'environnement). #} +
@@ -64,9 +65,10 @@ + From acaae92e3ae4cd2318bc2cc448fc46b2d2bab4a7 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:10:51 -0400 Subject: [PATCH 2/7] Fusion #69 : Alertes unifiees (app/main.py) --- app/main.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/main.py b/app/main.py index da57dc7..68f25cc 100644 --- a/app/main.py +++ b/app/main.py @@ -239,6 +239,18 @@ app.add_middleware(RateLimitMiddleware) _CORS_ORIGINS = sorted( {o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))} ) +# Fusion #69 : le panneau Alertes unifiées lit /api/notifications +# depuis les hôtes soeurs, avec credentials — leurs origines doivent +# donc figurer dans allow_origins (résolution draco_urls : explicite +# > hôte de la requête > dérivation > défaut officiel ; hors requête, +# la configuration seule). Lecture seule (GET) ; les POST restent +# soumis au CSRFMiddleware. +from app.services import draco_urls as _draco_urls69 +_toutes69 = _draco_urls69.toutes() +_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | { + _draco_urls69.origine(_toutes69[k]) + for k in ("obsigate", "flowdeck", "tiktak", "kanbante") +} - {""}) # Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement — # pas de cookie → `allow_credentials` ne s'applique pas à ces origines). app.add_middleware( From 52b62b48b88ca72371331156cd14d2e5dd1189b2 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:10:55 -0400 Subject: [PATCH 3/7] Fusion #69 : Alertes unifiees (app/middleware/security.py) --- app/middleware/security.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/middleware/security.py b/app/middleware/security.py index 1a65711..0541cf6 100644 --- a/app/middleware/security.py +++ b/app/middleware/security.py @@ -139,7 +139,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware): # l'agent connu en config ; défaut = nom officiel). # Même origine pour frame-src : la console du service est # encadrée en iframe par le panneau (surimpression v3). - "connect-src 'self' {agent} ws://{host} wss://{host}; " + "connect-src 'self' {agent} {sisters} ws://{host} wss://{host}; " "media-src 'self' blob:; " "frame-src 'self' {agent}; " "object-src 'none'; " @@ -174,8 +174,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware): request.headers.get("host", "")) agent = re.sub(r"[^0-9A-Za-z.\-:/]", "", draco_urls.origine(draco_urls.agent_url())) + # Fusion #69 : le panneau Alertes unifiées lit les API + # notifications des applications soeurs depuis cet hôte ; + # leurs origines rejoignent connect-src (même résolution + # draco_urls que les liens de la barre et frame-src). + _toutes69 = draco_urls.toutes() + sisters = " ".join(sorted({ + draco_urls.origine(_toutes69[k]) + for k in ("obsigate", "flowdeck", "tiktak", "kanbante") + } - {""})) response.headers[self.CSP_HEADER] = self.CSP_VALUE.format( - nonce=nonce, host=host, agent=agent + nonce=nonce, host=host, agent=agent, sisters=sisters ) return response From ab63000f5d7331df58ca3de9ebd4aae92433107d Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:11:00 -0400 Subject: [PATCH 4/7] Fusion #69 : Alertes unifiees (static/js/draco-alertes.js) --- static/js/draco-alertes.js | 427 +++++++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 static/js/draco-alertes.js diff --git a/static/js/draco-alertes.js b/static/js/draco-alertes.js new file mode 100644 index 0000000..b2aed2c --- /dev/null +++ b/static/js/draco-alertes.js @@ -0,0 +1,427 @@ +/* Alertes unifiées dracodev — Fusion #69 (chantier 2). + * + * 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 (« Connexion requise ») + * avec un lien profond vers sa page native, jamais du vide déguisé. + * + * Sources réelles (audit #69, 2026-10-28) : + * - 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. La section + * ObsiGate le dit et renvoie vers l'accueil d'ObsiGate. + * + * 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] ; + * 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). + * 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. + * + * 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/" + }; + 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/" + }; + var APP_LABELS = { + obsigate: "ObsiGate", flowdeck: "Flowdeck", + tiktak: "TikTak", kanbante: "Kanbante" + }; + var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"]; + var EVENT_LABELS = { + created: "créée", moved: "déplacée", closed: "fermée", + reopened: "rouverte", commented: "commentée" + }; + + 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 s ? String(s).slice(0, 16) : ""; + try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); } + } + + /* Résolution des bases. `env` permet les tests sans navigateur : + * { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */ + 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 = {}; + APP_ORDER.forEach(function (app) { + var u = cleanUrl(env.barUrls && env.barUrls[app]) + || cleanUrl(env.configUrls && env.configUrls[app]); + if (!u && dev) u = DEV_URLS[app] || null; + if (!u) { + var href = null; + if (env.tabHrefs && env.tabHrefs[app]) href = 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[app]) u = cleanUrl(env.dracoUrls[app]); + if (!u) { + /* ObsiGate : window.DRACO_URLS injecté par le backend. */ + try { + if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]); + } catch (e) { /* rien */ } + } + if (!u) u = DEFAULT_URLS[app]; + urls[app] = 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 + }; + } + + 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 || "")) + }; + }); + } + /* Actives d'abord, puis date décroissante (sans date en dernier). */ + 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; + } + + /* ---- Rendu (navigateur seulement) ---- */ + var panel = null, listEl = null, badgeTimer = null; + + 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 note = 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."); + var close = el("button", "draco-alertes-close", "✕"); + close.setAttribute("aria-label", "Fermer"); + close.addEventListener("click", closePanel); + head.appendChild(close); + box.appendChild(head); + box.appendChild(note); + listEl = el("div", "draco-alertes-list"); + 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") closePanel(); + }); + document.body.appendChild(ov); + panel = ov; + return ov; + } + function closePanel() { if (panel) panel.style.display = "none"; } + + function renderSection(title, items, fallback) { + var sec = el("section", "draco-alertes-section"); + sec.appendChild(el("h3", "", title + " (" + items.length + ")")); + if (!items.length) { + sec.appendChild(el("p", "draco-alertes-empty", fallback)); + return sec; + } + items.forEach(function (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)); + var meta = el("div", "draco-alertes-meta", + APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") + + " · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state); + main.appendChild(meta); + row.appendChild(main); + sec.appendChild(row); + }); + return sec; + } + function render(results, urls) { + ensurePanel(); + listEl.textContent = ""; + var all = []; + APP_ORDER.forEach(function (app) { + (results[app] && results[app].items || []).forEach(function (a) { all.push(a); }); + }); + var actives = sortAlerts(all.filter(function (a) { return a.active; })); + var history = sortAlerts(all.filter(function (a) { return !a.active; })); + listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives")); + APP_ORDER.forEach(function (app) { + var items = actives.filter(function (a) { return a.app === app; }); + if (results[app] && results[app].error) { + var sec = renderSection(APP_LABELS[app], [], results[app].error); + listEl.appendChild(sec); + } else if (app === "obsigate") { + listEl.appendChild(renderSection(APP_LABELS[app], [], + "ObsiGate n'expose aucune notification entrante consultable " + + "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs).")); + } else if (items.length) { + listEl.appendChild(renderSection(APP_LABELS[app], items, "")); + } + }); + if (!actives.length) { + listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables.")); + } + listEl.appendChild(el("h3", "draco-alertes-group", "Historique")); + APP_ORDER.forEach(function (app) { + if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */ + if (app === "obsigate") { + var link = el("div", "draco-alertes-empty"); + var a = el("a", "", "Ouvrir ObsiGate"); + a.href = urls.obsigate; + link.appendChild(a); + var sec = el("section", "draco-alertes-section"); + sec.appendChild(el("h3", "", "ObsiGate (0)")); + sec.appendChild(link); + listEl.appendChild(sec); + return; + } + var items = history.filter(function (x) { return x.app === app; }); + listEl.appendChild(renderSection(APP_LABELS[app], items, + "Aucun historique disponible pour cette application.")); + }); + } + + 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 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) }; }) + }; + 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]; }); + 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 all = []; + APP_ORDER.forEach(function (app) { + (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); + }); + setBadge(countActive(all)); + return countActive(all); + }).catch(function () { /* pastille optionnelle : la barre vit sans */ }); + } + function openPanel() { + ensurePanel(); + panel.style.display = "flex"; + listEl.textContent = ""; + listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…")); + loadAll().then(function (r) { + render(r.results, r.urls); + var all = []; + APP_ORDER.forEach(function (app) { + (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); + }); + setBadge(countActive(all)); + }).catch(function () { + render({ obsigate: { items: [] } }, resolveUrls(browserEnv())); + }); + } + + var api = { + open: openPanel, close: closePanel, refreshBadge: refreshBadge, + _test: { + resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck, + normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante, + sortAlerts: sortAlerts, countActive: countActive, + isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS + } + }; + 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). */ + if (typeof document !== "undefined" && document.addEventListener) { + document.addEventListener("DOMContentLoaded", function () { + if (badgeTimer) clearTimeout(badgeTimer); + badgeTimer = setTimeout(refreshBadge, 300); + }); + } +})(typeof window !== "undefined" ? window : globalThis); From fbe9151ab71980b125985c428e039c395e7d0dc0 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:11:05 -0400 Subject: [PATCH 5/7] Fusion #69 : Alertes unifiees (static/css/draco-alertes.css) --- static/css/draco-alertes.css | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 static/css/draco-alertes.css diff --git a/static/css/draco-alertes.css b/static/css/draco-alertes.css new file mode 100644 index 0000000..0d0582e --- /dev/null +++ b/static/css/draco-alertes.css @@ -0,0 +1,39 @@ +/* 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; } From 9abd6c9d4d327313338cde3099daa19a1c09b552 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:11:10 -0400 Subject: [PATCH 6/7] Fusion #69 : Alertes unifiees (tests/test_draco_alertes.py) --- tests/test_draco_alertes.py | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/test_draco_alertes.py diff --git a/tests/test_draco_alertes.py b/tests/test_draco_alertes.py new file mode 100644 index 0000000..86c08f6 --- /dev/null +++ b/tests/test_draco_alertes.py @@ -0,0 +1,63 @@ +"""Fusion #69 — Alertes unifiées (chantier 2, Flowdeck). + +Source réelle de Flowdeck : GET /api/notifications (non lues = +actives, `url` relative pour le lien profond), déjà exposée et +inchangée ici. Couverts : placeholder Alertes disparu, module servi, +CSP connect-src avec les origines soeurs, CORS (origines soeurs +ajoutées à allow_origins ; tiers sans en-tête). +""" + +SOEURS = ("https://og.dracodev.net", "https://flowdeck.dracodev.net", + "https://tiktak.dracodev.net", "https://kanbante.dracodev.net") + + +def test_barre_alertes_sans_placeholder_et_module_servi(client): + # Les pages applicatives authentifiées ne sont pas atteignables + # telles quelles dans la sandbox (l'écran de connexion est servi + # pour "/" comme pour "/dashboard") : on vérifie donc le gabarit + # livré lui-même, plus les fichiers statiques servis. + from pathlib import Path + + gabarit = Path("app/templates/_draco_bar.html").read_text() + assert "Alertes unifiées : arrive avec la fédération" not in gabarit + assert "draco-alertes.js" in gabarit + assert "draco-alertes.css" in gabarit + assert "DRACO_ALERTES.open()" in gabarit + js = client.get("/static/js/draco-alertes.js") + assert js.status_code == 200 + assert "DRACO_ALERTES" in js.text + assert "api/notifications" in js.text + css = client.get("/static/css/draco-alertes.css") + assert css.status_code == 200 + assert "draco-alertes-overlay" in css.text + + +def test_csp_connect_src_contient_les_soeurs(client): + # La CSP est posée sur toute réponse HTML, y compris l'écran de + # connexion servi ici : elle prouve les origines soeurs en + # connect-src pour les pages de l'hôte. + r = client.get("/") + csp = r.headers.get("content-security-policy", "") + assert "connect-src" in csp + for origine in SOEURS: + assert origine in csp, csp + + +def test_notifications_api_et_cors_soeur(client): + r = client.get("/api/notifications") + assert r.status_code == 200 + body = r.json() + assert isinstance(body["notifications"], list) + assert "unread" in body + r2 = client.options("/api/notifications", + headers={"Origin": "https://tiktak.dracodev.net", + "Access-Control-Request-Method": "GET"}) + assert r2.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net" + assert r2.headers.get("access-control-allow-credentials") == "true" + r3 = client.get("/api/notifications", + headers={"Origin": "https://tiktak.dracodev.net"}) + assert r3.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net" + r4 = client.options("/api/notifications", + headers={"Origin": "https://evil.example", + "Access-Control-Request-Method": "GET"}) + assert "access-control-allow-origin" not in r4.headers From 7f9eb080b142881c0cc0cf9768e1c64517cb260e Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:11:15 -0400 Subject: [PATCH 7/7] Fusion #69 : Alertes unifiees (tests/draco-alertes.test.js) --- tests/draco-alertes.test.js | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/draco-alertes.test.js diff --git a/tests/draco-alertes.test.js b/tests/draco-alertes.test.js new file mode 100644 index 0000000..dc5371f --- /dev/null +++ b/tests/draco-alertes.test.js @@ -0,0 +1,84 @@ +// Tests unitaires (node:test) du module partagé draco-alertes.js. +// Exécutés dans la sandbox : node --test draco-alertes.test.js +// Aucune donnée inventée : les payloads reprennent la forme exacte +// des API réelles (Flowdeck /api/notifications, TikTak +// /api/notifications, Kanbante /api/events). +const test = require("node:test"); +const assert = require("node:assert/strict"); +const t = require("../static/js/draco-alertes.js"); + +test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => { + const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} }); + assert.equal(prod.tiktak, "https://tiktak.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/"); + 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/" }, + }); + assert.equal(href.kanbante, "https://kanbante-dev.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); +});