Compare commits

...
9 Commits
Author SHA1 Message Date
bruno 6d7af3fb64 feat: éditeur visuel d'automations (pipeline steps) + fix CSP multi-instructions (v7.45.0)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m9s
Added — Settings → Automations, pipeline visuel (API steps v7.0) :
- Cartes ordinees (trigger/condition/delay/action) : resume + edition
  TYPÉE par kind/type (datalist evenements, 7 ops, 8 types d'action avec
  leurs champs reels), ajout/edition/suppression/haut-bas via
  POST/PUT/DELETE /workspace/automations[/steps]/...
- ✨ Convertir le JSON en pipeline (legacy → steps ordonnes) ; textareas
  JSON masques des qu'un step existe.
- Gate E2E « editeur visuel de steps » : creation → edition → ajout →
  carte « Action · webhook » sous CSP reel.

Fixed (trouve par le gate) — 51 expressions Alpine MULTI-INSTRUCTIONS
(`a=1; b()`) = interdites par le parseur CSP (une seule expression par
directive ; ';' = token inattendu) — INVISIBLE pour le scan par tokens :
- Conversion en methodes dans 11 fichiers : nav settings x8 (navTo),
  menu section base x7 (closeAndSetCount/Move/...), parts+editeur x13
  (setSharePerm, more*, markAndSave...), breadcrumb x5 (hover*/goClose),
  library/local x6 (menus popup), board x3 (pickStatus/...), ctx-menu x2
  (addTagAndClear), agent/card/gitea/workspaces x6.
- Scanner dedie scan_semi (inventaire ';' hors chaines) ajoute au lot.

Verifs : 39 templates Jinja parse OK · scan expressions = 0 incompatible
(4 faux positifs en chaines) · **E2E 8/8** · suite **1094/1094** ·
ruff OK · docs a jour
2026-10-02 16:56:29 -04:00
bruno b0af1bbfb6 feat: palette Ctrl+K — onglets Pages / ✨ Réponses IA (v7.44.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m21s
FlowDeck CI / docker (push) Successful in 1m51s
Added :
- Onglets dans la palette (markup + CSS + wiring IIFE) :
  · Pages — comportement inchange (recherche /api/search + actions).
  · ✨ Réponses IA — POST /api/v2/search/ask (debounce 150 ms, CSRF via
    getCsrf(), gardes staleness onglet+requete) → answer_markdown rendu :
    echappement AVANT injection, [[fdpage:ID]] → lien citation (ids =
    chiffres, type = [a-z]+ — pas d'injection), **gras**, bloc « Sources »
    avec liens /pages/{id} · /db/{id} ; etats hint/chargement/erreur.
- Gate E2E etendu : ouverture → clic onglet IA → reponse non-echappee.
  (probe : ask = 200 en 1,6s, 8 citations, provider actif)

Reporte (backend absent) : onglet Fichiers — /api/search ne renvoie que
pages/collections → endpoint a creer d'abord (ROADMAP, entree mise a jour).

Notes : 401 transitoire sur le 1er ask d'un run E2E observe une fois
(session fraiche), non reproductible ensuite — signale CHANGELOG.

suite **1094/1094** · ruff OK · E2E **7/7** · docs a jour
2026-10-02 16:27:52 -04:00
bruno de751ffe35 feat: A20 TERMINÉ — Alpine en build CSP, unsafe-eval retiré de la CSP (v7.43.0)
FlowDeck CI / test (push) Successful in 15m24s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / docker (push) Successful in 1m52s
La bascule A20 phase 3 :
- static/js/alpine.csp.min.js (build officiel @alpinejs/csp, 0
  eval/new Function, parseur maison) servi partout : base.html,
  import.html, welcome.html + entree sw.js (cache bump v8).
- CSP : script-src 'self' 'nonce-…' — unsafe-eval SUPPRIMÉ (ne servait
  plus qu'Alpine standard). htmx allowEval:false deja pose (v7.37).
- Assertion test inversée : assert "'unsafe-eval'" not in script_src.
- Scan statique final sur TOUS les templates : 0 expression incompatible
  (4 residus = faux positifs dans des chaines de texte).

Pré-requis réunis par les lots 1-3 : 12 surfaces migrées + gateées
(csp_preview), registres Alpine.data, x-html → x-init+Alpine.effect,
délégués window.E, partage d'état lexical, bug topbar corrigé.

Verifs : suite **1094/1094** · ruff OK · eslint 0/0 · **E2E 7/7 sous
CSP reel** (script-src sans unsafe-eval verifie sur l'instance).

Hors gate (scan propre, gitea down) : board/table_view/teamload/
card_detail → à vérifier au premier usage avec gitea remonté (noté
ROADMAP/CHANGELOG).
2026-10-02 16:00:12 -04:00
bruno 48b5551b93 fix: BUG TOPBAR — boutons du header servis échappés sur toutes les pages (v7.42.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
Cause racine : {% set right_actions = '…' ~ fd_icon(…) ~ '…' %} —
fd_icon est une macro → Markup, et Markup.__radd__/__add__ ÉCHAPPE ses
arguments str → tous les segments littéraux sortent entité-és ("/<),
et le |safe de _header:141 est no-op sur un Markup déjà échappé.
Régression probable depuis A10 (activation d'autoescape).

Fix (5 templates, forme idiomatique) : conversion en block-set
{% set right_actions %}…{{ fd_icon(…) }}…{% endset %} — source brute,
interpolation Markup brute : gitea_workspace, page_editor,
page_editor_collection, workspace, workspaces. (Piège script : regex
greedy multi-lignes avalait le set suivant → matcher sur UNE ligne.)

Tests :
- NOUVEAU tests/test_topbar_right_actions.py (permanent) : /workspaces
  doit servir class="topbar-btn" parsé et ZÉRIE entité "
- gate éditeur CSP : assertion .star-btn RÉTABLIE (les boutons rendent)
- debug temporaires (DBGCLS/DBGVAL) retirés

suite **1094/1094** (+1 nouveau test) · ruff OK · E2E **7/7** (5
csp_preview + 2 smoke) · docs à jour (CHANGELOG détail, ROADMAP bug →
CORRIGÉ)
2026-10-02 15:39:57 -04:00
bruno e4552c3763 chore: probe Playwright jetable retirée (e2e/_probe_edit.js)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
2026-10-02 15:15:58 -04:00
bruno 6914780f24 feat: A20 phase 3 LOT 3b — gitea + agent + éditeur verts en CSP (v7.41.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
Changed :
- gitea_workspace : x-data="giteaWorkspace" → appel giteaWorkspace(),
  new Date(…) → fmtGwDate(pp), x-html icône arbre → bindGwIcon (x-init +
  Alpine.effect).
- agent_panel : x-html markdown → bindMarkdown($el, m) (effet reactif).
- page_editor : les 12 sites window.E du topbar right_actions →
  délégués appState (edCall('…') x6, edTimeAgo, edCommentCount, edShared,
  bindStar — les 2 branches du ternaire favorited étaient identiques) ;
  + 3 sites dans _page_editor_content (edCall commentOnSelection,
  openBacklink, fmtImportSize, bindIconHtml). Garde Jinja : quotes \' dans
  le set délimité par ' (quote nue = 500).
- Gate éditeur (csp_preview) : création collection → /pages/{id},
  délégués + editorState liés, filet 0-erreur.

Fixed :
- x-html iconHtml() du contenu éditeur = directive INTERDITE sous build
  CSP (attrapé par le filet) → x-init + Alpine.effect.

⚠️ BUG pre-existant identifie (pas introduit ici) : les right_actions du
topbar sont servis ÉCHAPPÉS sur TOUTES les pages (entities "/< —
boutons Share/Star/Settings en texte brut). _header:141 a bien |safe,
ENV standard, rendu local = PARSED ; cause serveur à cerner → suivi
ROADMAP dédié. Le gate éditeur n'asserte donc pas la présence boutons.

suite **1093/1093** · ruff OK · E2E **7/7** (5 csp_preview + 2 smoke)
· docs a jour
2026-10-02 15:15:26 -04:00
bruno d7d9966edf feat: A20 phase 3 LOT 3a — 5 surfaces CSP vertes + fix bug /import (v7.40.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajout :
- Gate csp_preview « surfaces simples » : /welcome, /trash, /accounts,
  /workspace, /import — 0 modification necessaire sur les 4 premieres
  (scan statique 0 expression/x-html + registres Alpine.data du lot 1).
  8 surfaces couvertes au total.

Fixed (pre-existant, visible sous les DEUX builds) :
- /import : x-text "'🔗 '+report.relations…" evalue avec report=null
  (le x-show parent ne masque pas, il initialise quand meme) →
  pageerror « Cannot read property ... 'relations' » → garde
  report && report.relations.

Reste ph3 documente dans ROADMAP : page_editor (12 sites window.E dans
right_actions), gitea_workspace (new Date), agent_panel (x-text+x-html
markdown), board/table_view/teamload/card_detail (scan propre, gates lies
au contexte Gitea) → bascule reel ensuite.

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **6/6** (4 csp_preview +
2 smoke) · docs a jour
2026-10-02 13:49:11 -04:00
bruno 3cab76fed5 feat: A20 phase 3 LOT 2 — settings + local workspace verts en CSP preview (v7.39.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajout :
- 2 gates csp_preview de plus : settings (composant lie, overlay visible)
  et local workspace (recherche focalisee via Alpine.nextTick, chips
  filtre en SVG via bindSvg, 0 erreur) → 3 surfaces vertes sous build
  CSP : library, settings, local workspace.

Changed :
- settings : window.history.back()/new Date(...) → methodes
  historyBack/fmtLastLogin/fmtAuditDate ; ?. → ternaires.
- local workspace : x-data="_wsInitData" → registre wsInitData() ;
  14 x-html → x-init + Alpine.effect (bindSvg/bindFileIcon/bindNodeIcon/
  bindChildren/bindPreview) ; $nextTick+$refs arrow → toggleSearch() ;
  window.FlowDeck.* → createPageAt/createFolderAt ; ?. → ternaires ;
  @contextmenu="_wsInitData.*" → appel de methode.

Piesges resolus (CHANGELOG en details) :
- snapshot ji du build CSP = valeurs globalThis au boot → l'objet mis sur
  window avant Alpine est banni (« Accessing global variables ») → objet
  porte par une CONST LEXICALE (non propriete globalThis) + factory
  Alpine.data → MEME objet partage, reactivite intacte.
- bloc preview hors div racine (structure pre-existante, masquee par le
  fallback window standard) → composant wsPreview DELEGUANT vers
  _wsInitData via Alpine.reactive (wrapper unique : les magics $nextTick
  ne sont redefinissables qu'une fois).
- .env local : RATE_LIMIT_REQUESTS=600 (rafales E2E vs 60/min par IP ;
  defaut produit inchange).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **5/5** (3 csp_preview + 2
smoke) · CSP preview ET standard = 0 erreur sur /local-workspace · docs
a jour
2026-10-02 13:24:46 -04:00
bruno 6ff88237fc feat: A20 phase 3 LOT 1 — shell + library migres, harnais csp_preview vert (v7.38.0)
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m32s
FlowDeck CI / docker (push) Successful in 1m51s
Ajout :
- e2e/csp_preview.spec.js — apercu CSP strict SANS deployer : le build
  officiel @alpinejs/csp (e2e/fixtures/alpine.csp.js, 0 eval) est servi a
  la place de alpine.min.js par interception Playwright ; toute expression
  que le parseur maison ne digere pas = pageerror (filet). Premiere
  surface VERTE : library (composant lie, icones SVG via Alpine.effect,
  recherche ouverte + focalisee, 0 erreur).

Changed :
- 16 composants x-data="fn()" enregistres via Alpine.data (registre =
  seule resolution du build CSP, probe « Undefined variable » ;
  scripts classiques executes pendant le parsing => alpine:init toujours
  joint) : appState, libraryPage, workspacesPage, editorState, board x4,
  settings/import/table_view/team_load/trash/workspace/welcome/accounts/
  card_detail.
- base.html (shell) migre : x-effect document.* -> syncSidebarClass(),
  $nextTick(arrow) -> initSidebarSort(), window.FlowDeck.* ->
  fdCreatePage/fdCreateFolder/fdGwRefresh, Object.keys/Math.min/
  window.innerWidth dans x-for et :style -> sidebarSections()/
  sectionMenuPos() — tout = simple appel de methode.
- x-html restants du shell -> x-init + Alpine.effect : icone agent,
  carte projet, library x3 ; recherche library -> toggleSearch()
  (Alpine.nextTick) ; openMoveSelected() pour Object.keys en expression.
- eslint : 70 warnings -> 0/0 (globals getCsrf depuis A38 ph1,
  /* exported openCardDetail */ + /* global owner, repo */, 3 ;; residuels).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E 3/3 (csp_preview + smoke x2)
· docs a jour (ROADMAP ph3 LOT 1, CHANGELOG, WORKLOAD, VERSION)
2026-10-02 12:08:18 -04:00
48 changed files with 1407 additions and 153 deletions
+257
View File
@@ -1,5 +1,262 @@
# Changelog - FlowDeck
## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
### Added
- **Pipeline visuel (steps) dans Settings → Automations** : cartes
ordonnées par étape avec badge + résumé (`📡 Déclencheur · event`,
`⚖ Condition · prop op val`, `⏳ Attente · Xs`, `⚡ Action · type → …`),
**éditeur typé par kind/type** (événements en datalist, 7 ops de
condition, 8 types d'action avec leurs champs réels : url, property/value,
collection/title, message, webhook_url/text, to/subject/body,
owner/repo/labels, agent_id/message), ajout/édition/suppression/**↑↓** via
`POST/PUT/DELETE /workspace/automations[/steps]/…`, conversion legacy
**✨ Convertir le JSON en pipeline** (trigger + conditions + actions
ordonnés) ; les textareas JSON disparaissent dès qu'un step existe.
- **Gate E2E** « éditeur visuel de steps » : création → édition → ajout
d'étape → carte `Action · webhook` visible (sous CSP réel).
### Fixed (trouvé par le gate)
- **51 expressions Alpine multi-instructions** (`activeSection='x';
loadX()` etc.) = **interdites par le parseur CSP** (une seule expression
par directive ; `;` = token inattendu) — invisible pour le scan par
tokens ! Conversion en **méthodes** : settings nav ×8 (`navTo`), menu de
section base ×7 (`closeAndSetCount/…`), parts/éditeur ×13
(`setSharePerm`, `more*`, `markAndSave`…), breadcrumb ×5
(`hoverEllipsis/goClose`), library/local ×6 (menus), board ×3
(`pickStatus/…`), ctx-menu ×2 (`addTagAndClear`), agent/card/gitea/
workspaces ×6. Scanner dédié `scan_semi` (inventaire `;` hors chaînes).
### Notes
- 39 templates Jinja parse OK, scan d'expressions = 0 incompatibilité
(4 faux positifs en chaînes), E2E **8/8**, suite **1094/1094**.
## v7.44.0 (2026-10-01) — Palette Ctrl+K : onglets Pages / ✨ Réponses IA
### Added
- **Palette `Ctrl+K` = 2 onglets** (markup + CSS + wiring dans l'IIFE) :
· **Pages** — comportement inchangé (recherche `/api/search` + actions).
· **✨ Réponses IA** — `POST /api/v2/search/ask` (debounce 150 ms,
CSRF via `getCsrf()`, gardes staleness onglet+requête) → rendu de
`answer_markdown` : **échappement AVANT injection**, `[[fdpage:ID]]` →
lien citation (ids = chiffres, type = `[a-z]+` — pas d'injection),
`**gras**`, bloc « Sources » avec liens `/pages/{id}` · `/db/{id}`.
États : hint / chargement / erreur.
- **Gate E2E étendu** (`gate A20 palette`) : ouverture → clic onglet IA →
réponse non-échaffée affichée (le backend tourne en extractif ou LLM —
probe : 200 en 1,6 s, 8 citations).
- **Reporté (backend absent)** : onglet `Fichiers` — `/api/search` ne
renvoie que `pages`/`collections` → endpoint à créer d'abord (ROADMAP).
### Notes
- Un 401 transitoire sur le 1er ask d'un run E2E a été observé (session
fraîche) — non reproductible ensuite ; à surveiller si ça revient.
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
### Changed (la bascule A20 phase 3)
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
v8).
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
(v7.37).
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
- Scan statique final sur **tous** les templates : 0 expression incompatible
(4 résidus = faux positifs dans des chaînes de texte).
### Notes
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
CSP réel** (le route-swap du `csp_preview` sert désormais le même
fichier — les gates restent utiles contre une régression du build).
- board/table_view/teamload/card_detail : scan propre mais non gateés
(gitea down) → à vérifier au premier usage avec gitea remonté.
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
### Fixed
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
pages** (entities `"`/`<` → boutons Share/Star/Settings/Login en
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
**échappe ses arguments `str`** → tous les segments littéraux sortent
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
reproductible partout (`curl /workspaces`).
- **Fix racine (5 templates)** : conversion en **block-set**
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
interpolation = Markup brut — la forme idiomatique Jinja) :
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
matcher **une ligne**.
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `"`.
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
rendent à nouveau).
### Notes
- Probable régression depuis A10 (activation d'autoescape) : les `~`
étaient des no-op avant, Markup.__radd__ échappait déjà… les
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
### Changed
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
(x-init + `Alpine.effect`).
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
sur `m.content`).
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
templates `page_editor.html` + `page_editor_collection.html` + garde
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
`'`, une quote nue casse le template = 500).
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
`bindIconHtml` (effet sur `iconHtml()`).
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
délégués `appState` liés + `editorState` lié + filet 0-erreur.
### Fixed
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
pages** (entities `"`/`<` — les boutons Share/Star/Settings/…
s'affichent en texte brut). Reproductible : `curl /workspaces` →
`"topbar-btn"`. Le `{{ right_actions|safe … }}` de `_header:141`
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
n'asserte donc pas la présence des boutons.
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
### Added
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
`/accounts`, `/workspace`, `/import` — **0 modification de code
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
(base shell, library, settings, local workspace + celles-ci).
### Fixed
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
« Cannot read property … 'relations' » — garde `report && report.relations`.
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
périmètre, non touché.)
### Notes
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
contexte Gitea, scan statique propre) → bascule réelle ensuite.
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
### Added
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
visible, 0 erreur) et `local workspace` (recherche focalisée via
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
### Changed
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
`@contextmenu="_wsInitData.…"` → appel de méthode.
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
sur `window` avant Alpine est **banni** (« Accessing global variables is
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
mises à jour JS : preview, uploads, isDragging).
- **Bloc preview hors div racine** (structure pré-existante : le parseur
referme la racine avant, masquée par le fallback window d'Alpine
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
montage du même objet = « Cannot redefine property »).
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
défaut produit inchangé.
### Notes
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
puis bascule réelle (retrait `unsafe-eval`).
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
### Added
- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le
build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`)
est servi **à la place** de `alpine.min.js` par interception Playwright ;
tout échec du parseur maison = `pageerror` (filet). **Première surface
verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`,
recherche ouverte + focalisée, 0 erreur console/page).
### Changed
- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 +
`appState` + `libraryPage`) : le build CSP ne résout que le registre
(probe : globale window → `Undefined variable`) — scripts/classiques
chargés pendant le parsing = `alpine:init` toujours joint à temps.
- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`,
`$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` →
`fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/
`window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/
`sectionMenuPos()` — toutes les formes = simple appel de méthode.
- **x-html restants du shell** → `x-init` + `Alpine.effect` :
icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`),
library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library
(`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library).
- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans
les globals du config, `/* exported openCardDetail */` +
`/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion
A38 supprimés.
### Notes
- Portes A20 ph3 : surfaces restantes = settings, local_workspace,
gitea_workspace, page_editor, board (partiels), agent_panel, import,
welcome, accounts, trash, team_load, workspace, table_view, card_detail ;
**bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert
sur toutes les pages principales.
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
### Changed
+9 -3
View File
@@ -987,7 +987,7 @@ Détails livrés :
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
- [x] **UI** — palette `Ctrl+K` : **onglets `Pages / ✨ Réponses IA`** livrés (v7.44.0) — onglet IA = `POST /api/v2/search/ask` (CSRF via `getCsrf()`, debounce 150 ms, échappement AVANT injection des liens `[[fdpage:ID]]` → citations cliquables + bloc « Sources ») ; gate E2E étendu (onglet cliqué → réponse non-échappée). **Reporté** : onglet `Fichiers` — aucun backend (`/api/search` ne renvoie que `pages`/`collections`) → à créer d'abord.
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
@@ -1001,7 +1001,7 @@ Détails livrés :
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/réordonnancement (↑↓) via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
@@ -1137,7 +1137,13 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant (library → settings → local_workspace → gitea → base) avec gate E2E dédiée par surface, puis retrait `unsafe-eval`. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
- [x] **A20 — CSP sans filet — ✅ TERMINÉ 2026-10-01 (v7.43.0)** : `script-src = 'self' 'nonce-…'` (**plus aucun `unsafe-eval`**) (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
**BASCULE RÉELLE 2026-10-01 (v7.43.0)** : `alpine.csp.min.js` servi partout (base/import/welcome + sw), `unsafe-eval` retiré de la CSP (assert test inversée), scan statique final = **0** (4 faux positifs dans des chaînes), E2E 7/7 **sous CSP réel** + suite 1094/1094. board/table_view/teamload/card_detail : scan propre, non gateés (gitea down) — à vérifier au premier usage avec gitea remonté.
**🐛 BUG TOPBAR — CORRIGÉ 2026-10-01 (v7.42.0)** : cause racine = `'literal' ~ fd_icon(…)` — `fd_icon` = macro → `Markup`, et `Markup.__radd__/__add__` **échappe les segments `str` littéraux** → boutons en entities partout ; `|safe` no-op sur Markup déjà échappé. Fix : **block-set** `{% set right_actions %}…{{ fd_icon() }}…{% endset %}` dans les 5 templates (gitea, page_editor ×2, workspace, workspaces) + **test permanent** `tests/test_topbar_right_actions.py` + assertion `.star-btn` rétablie dans le gate éditeur. Probable régression depuis A10 (autoescape). `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
+1 -1
View File
@@ -1 +1 @@
7.37.0
7.45.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.37.0 (A20 htmx allowEval off + probe Alpine CSP = plan phase 3 (13 exprs + 24 x-html)) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.45.0 (Éditeur visuel d'automations = pipeline steps CRUD + conversion JSON + **fix CSP : 51 expressions multi-instructions → méthodes** (30 fichiers-tpl)) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.37.0",
version="7.45.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+5 -7
View File
@@ -71,17 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` : Alpine STANDARD (x-data) en a besoin. htmx n'y touche
# plus (`allowEval: false` dans le meta htmx-config — 0 hx-on/hx-vars).
# Retrait = A20 phase 3 : build `@alpinejs/csp` (testé : 0 eval, OK sur
# probe) mais bloqué par 13 expressions non parsables (arrows/typeof/new/
# ?.) + 24 `x-html` réactifs (icônes SVG) → refonte des composants en
# Alpine.data — voir ROADMAP.
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
# = props propres, gitea down empêche un gate dédié).
CSP_VALUE = (
"default-src 'self'; "
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
"script-src 'self' 'nonce-{nonce}'; "
"script-src-attr 'unsafe-inline'; "
# ponytail: aucun @font-face Google (grep négatif) → les deux
# hôtes fonts étaient morts, supprimés.
+2 -2
View File
@@ -163,9 +163,9 @@
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
</div>
</div>
</div>
+8 -5
View File
@@ -60,14 +60,14 @@
{# ── Collapsed "…" segment ── #}
<template x-if="crumb.ellipsis">
<span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
@mouseenter="hoverEllipsis()"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link crumb-ellipsis">&hellip;</button>
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="h in crumb.hidden" :key="h.id">
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
<div class="fd-nav-item" @click.stop="goClose(h.url)">
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
<span class="fd-nav-label" x-text="h.label"></span>
</div>
@@ -91,7 +91,7 @@
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
<template x-if="!crumb.ellipsis && crumb.menu">
<span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
@mouseenter="hoverMenu(crumb.origIndex)"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link"
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
@@ -110,7 +110,7 @@
<template x-for="item in activeItems" :key="item.id">
<div class="fd-nav-item"
@mouseenter="openSub(item)"
@click.stop="go(item.url); closeAll()">
@click.stop="goClose(item.url)">
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
<span class="fd-nav-label" x-text="item.name"></span>
<span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span>
@@ -118,7 +118,7 @@
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="s in subItems" :key="s.id">
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
<div class="fd-nav-item" @click.stop="goClose(s.url)">
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
<span class="fd-nav-label" x-text="s.name"></span>
<span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span>
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
wsId: 0,
openIdx: null,
ellipsisOpen: false,
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
goClose(u){ this.go(u); this.closeAll(); },
loading: false,
cache: {},
activeItems: [],
+17 -17
View File
@@ -50,7 +50,7 @@
@keydown.enter.prevent="inviteEnter()"
@keydown.down.prevent="inviteMove(1)"
@keydown.up.prevent="inviteMove(-1)"
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
@keydown.escape="closeInviteSuggest()"
autocomplete="off"
/>
<button class="sd-btn-primary" @click="shareInvite()">
@@ -169,7 +169,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'edit' }"
@click="updateShare(a.id, 'edit'); a.permOpen=false"
@click="setSharePerm(a, 'edit')"
>
<span>Can edit</span>
<span class="sd-perm-desc"
@@ -179,7 +179,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'comment' }"
@click="updateShare(a.id, 'comment'); a.permOpen=false"
@click="setSharePerm(a, 'comment')"
>
<span>Can comment</span>
<span class="sd-perm-desc"
@@ -189,7 +189,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'view' }"
@click="updateShare(a.id, 'view'); a.permOpen=false"
@click="setSharePerm(a, 'view')"
>
<span>Can view</span>
<span class="sd-perm-desc"
@@ -199,7 +199,7 @@
<div class="sd-perm-sep"></div>
<div
class="sd-perm-option danger"
@click="removeShare(a.id); a.permOpen=false"
@click="removeShareAndClose(a)"
>
Remove
</div>
@@ -233,7 +233,7 @@
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'invited' }"
@click="generalAccess='invited'; accessMenuOpen=false"
@click="pickGeneralAccess('invited')"
>
<span>{{ fd_icon('lock',14) }} Only people invited</span>
<span class="sd-perm-desc"
@@ -243,7 +243,7 @@
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'anyone' }"
@click="generalAccess='anyone'; accessMenuOpen=false"
@click="pickGeneralAccess('anyone')"
>
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
<span class="sd-perm-desc"
@@ -312,7 +312,7 @@
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
</template>
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
</div>
</div>
</div>
@@ -498,16 +498,16 @@
</div>
<div class="more-menu-item" @click="movePage">↗ Move to</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
<div class="more-menu-item" @click="moreToggleLock()">
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
<div class="more-menu-item" @click="moreFullWidth()">
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
<div class="more-menu-item" @click="moreFontSmall()">
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
<div class="more-menu-item" @click="moreSaveTemplate()">
📑 Save as template
</div>
<div class="more-menu-sep"></div>
@@ -546,13 +546,13 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
</div>
<div class="page-title-block">
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
<div
class="page-title-input"
contenteditable="true"
id="_titleEl"
data-placeholder="New Page"
@input="dirty=true;save()"
@input="markAndSave()"
@keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false"
@@ -583,7 +583,7 @@
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="window.E && window.E.commentOnSelection()">
@click="edCall('commentOnSelection')">
💬 Comment
</button>
@@ -820,7 +820,7 @@
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
</template>
<template x-for="b in backlinksList" :key="b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
</a>
@@ -856,7 +856,7 @@
<template x-if="importFile">
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
<span x-text="fmtImportSize(importFile)"></span>
</div>
</template>
<div style="display:flex;gap:8px;margin-top:10px;">
+5
View File
@@ -105,6 +105,11 @@
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: accountsData »).
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
function accountsData() {
return {
profile: { full_name: '', email: '' },
+2 -2
View File
@@ -231,7 +231,7 @@
<div class="fd-ap-tabs">
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
</div>
<div class="fd-agent-body">
@@ -305,7 +305,7 @@
</div>
</div>
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
<template x-if="m.proposal">
<div class="fd-proposal-bar">
+146 -24
View File
@@ -126,7 +126,7 @@
};
</script>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
@@ -188,8 +188,8 @@
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
id="sidebar"
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
x-effect="syncSidebarClass()"
x-init="initSidebarSort()"
<!-- Header + dropdown wrapper -->
<div style="position:relative;">
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
@@ -272,8 +272,8 @@
</div>
{% if has_active_workspace %}
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
</div>
@@ -308,7 +308,7 @@
<span class="section-label">Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
</div>
@@ -330,7 +330,7 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
@@ -407,7 +407,7 @@
<ul class="sidebar-items" data-section="agents">
<template x-for="a in agentList" :key="a.id">
<li class="sidebar-item" @click="agentOpen(a.id)">
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
<span class="page-name" x-text="a.name"></span>
</li>
</template>
@@ -541,12 +541,7 @@
<button class="scp-done" @click="toggleCustomize()">Done</button>
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
meetings:{visible:true,order:2},recents:{visible:true,order:3},
favorites:{visible:true,order:4},agents:{visible:true,order:5},
shared:{visible:true,order:6},published:{visible:true,order:7}
})" :key="key">
<template x-for="(cfg, key) in sidebarSections()" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
@@ -595,40 +590,40 @@
<!-- Section options menu (⋮ dropdown) -->
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
:style="sectionMenuPos()"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
<div class="section-menu-item" @click="closeAndSetCount(5)">
<span class="smi-label">Show 5 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
<div class="section-menu-item" @click="closeAndSetCount(10)">
<span class="smi-label">Show 10 items</span>
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
<div class="section-menu-item" @click="closeAndSetCount(15)">
<span class="smi-label">Show 15 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
<div class="section-menu-item" @click="closeAndSetCount(20)">
<span class="smi-label">Show 20 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
<div class="section-menu-item" @click="closeAndMove('up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
<div class="section-menu-item" @click="closeAndMove('down')">
<span class="smi-icon">↓</span>
<span class="smi-label">Move down</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
<div class="section-menu-item" @click="closeAndToggleVisibility()">
<span class="smi-icon">👁</span>
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
<span class="smi-icon">⚙️</span>
<span class="smi-label">Customize sidebar</span>
</div>
@@ -1127,6 +1122,12 @@
}
};
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
// (probe : globale window → « Undefined variable: appState »).
document.addEventListener('alpine:init', function () {
Alpine.data('appState', appState);
});
function appState() {
return {
init() {
@@ -1462,6 +1463,10 @@
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
},
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
closeSectionMenu() {
this.sectionMenu.visible = false;
},
@@ -1568,6 +1573,55 @@
else window.location.href = url;
},
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
bindProjectIcon(el, p) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
},
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
// scope du topbar = appState) — voir right_actions de page_editor ──
edCall(name) {
if (window.E && typeof window.E[name] === 'function') window.E[name]();
},
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
edCommentCount() {
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
},
edShared() { return !!(window.E && window.E.pageIsShared); },
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
bindAgentIcon(el, a) {
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
},
syncSidebarClass() {
document.documentElement.classList.toggle(
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
},
initSidebarSort() {
Alpine.nextTick(() => {
if (typeof initTreeSortable === 'function') initTreeSortable();
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
return {
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
};
},
sectionMenuPos() {
return {
top: this.sectionMenu.y + 'px',
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
};
},
toggleSidebar() {
this.sidebarPeek = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
@@ -2184,6 +2238,14 @@
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
a.cp-cite:hover{text-decoration:underline}
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
@@ -2205,6 +2267,10 @@
<input id="fd-cp-input" class="cmd-palette-input" type="text"
placeholder="Search pages, databases, or type a command…"
autocomplete="off" spellcheck="false">
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
</div>
<div id="fd-cp-list" class="cmd-palette-list"></div>
<div class="cmd-palette-footer">
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
@@ -2225,7 +2291,7 @@
];
var overlay, input, list;
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function highlight(text, q){
@@ -2249,6 +2315,7 @@
}
function render(){
if(state.tab==='ai'){ renderAi(); return; }
if(!state.open) return;
if(!state.items.length){
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
@@ -2285,6 +2352,7 @@
}
function runSearch(){
if(state.tab==='ai'){ runAi(); return; }
var q = input.value.trim();
state.query = q;
state.index = 0;
@@ -2309,6 +2377,50 @@
.catch(function(){ state.items=[]; render(); });
}
function runAi(){
var q = input.value.trim();
state.ai = null;
if(!q){ state.ai = {hint:1}; renderAi(); return; }
state.ai = {loading:1}; renderAi();
fetch('/api/v2/search/ask', {
method:'POST',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({question: q})
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
.then(function(d){
if(state.tab!=='ai' || input.value.trim()!==q) return;
state.ai = d; renderAi();
})
.catch(function(){
if(state.tab!=='ai') return;
state.ai = {error:1}; renderAi();
});
}
function renderAi(){
var a = state.ai;
if(!a){ list.innerHTML=''; return; }
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
var cits = a.citations || [], byId = {};
cits.forEach(function(c){ byId[String(c.id)] = c; });
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
var md = esc(a.answer_markdown || '')
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
var c = byId[id];
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
})
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
var cites = cits.map(function(c){
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
}).join('');
list.innerHTML = '<div class="cmd-palette-ai">' + md +
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
}
function open(){
if(state.open) return;
state.open=true; overlay.classList.add('open');
@@ -2363,6 +2475,16 @@
list = document.getElementById('fd-cp-list');
if(!overlay) return;
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
var tabs = document.getElementById('fd-cp-tabs');
if(tabs) tabs.addEventListener('click', function(e){
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
if(!b) return;
state.tab = b.getAttribute('data-tab');
var all = tabs.querySelectorAll('.cp-tab');
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
state.index = 0;
runSearch();
});
document.addEventListener('keydown', onKey);
});
})();
+3 -3
View File
@@ -74,7 +74,7 @@
<template x-for="(f, i) in activeFilters" :key="i">
<div class="filter-pill">
<span x-text="f.property + ': ' + f.value"></span>
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
</div>
</template>
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
@@ -84,7 +84,7 @@
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
@click.stop>
<template x-for="s in statusOptions" :key="s.value">
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
<div class="dropdown-option" @click="pickStatus(s.value)">
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
<span x-show="statusFilters.includes(s.value)">✓</span>
</span>
@@ -95,7 +95,7 @@
</div>
</div>
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
<div class="view-toolbar-spacer"></div>
+7 -1
View File
@@ -95,11 +95,16 @@
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
resize:vertical; margin-top:8px;"
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
</div>
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: cardDetail »).
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
function cardDetail() {
return {
updateField(field, value) {
@@ -133,6 +138,7 @@
})
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
},
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
addComment(body) {
if (!body.trim()) return;
console.log('Add comment:', body);
+5 -5
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "link" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -36,7 +36,7 @@
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<div class="gw-main" x-data="giteaWorkspace()">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
@@ -120,7 +120,7 @@
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
@@ -165,7 +165,7 @@
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div>
@@ -180,7 +180,7 @@
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
<button class="btn" @click="closePrivateEdit()">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
*{margin:0;padding:0;box-sizing:border-box;}
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
</div>
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
+9 -9
View File
@@ -233,7 +233,7 @@
</div>
<div class="lib-toolbar">
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
@@ -245,7 +245,7 @@
</div>
</div>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
@@ -255,11 +255,11 @@
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
</div>
</div>
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
</button>
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
@@ -310,7 +310,7 @@
<button @click="copyLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
<button @click="openMoveSelected()" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
@@ -326,7 +326,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
<div class="menu-item" @click="openMoveSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
@@ -378,7 +378,7 @@
<!-- ── Empty state ── -->
<div class="lib-empty" id="lib-empty">
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
<h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p>
</div>
@@ -397,7 +397,7 @@
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)">
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
</div>
</template>
</div>
@@ -414,7 +414,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="peek-title">
<span x-html="_renderIcon(peekItem)"></span>
<span x-init="bindHtmlItem($el, peekItem)"></span>
<span x-text="peekItem.title"></span>
</div>
<button @click="openItem(peekItem)" title="Open full page">
+30 -27
View File
@@ -11,7 +11,7 @@
{% block content %}
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
<div x-data="_wsInitData"
<div x-data="wsInitData()"
@dragover.prevent="onDragOver($event)"
@dragleave="onDragLeave($event)"
@drop.prevent="onDrop($event)"
@@ -482,10 +482,10 @@
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
@contextmenu.prevent="onContextMenu($event)"
@touchstart="onTouchStart($event)"
@touchend="onTouchEnd($event)"
@touchmove="onTouchMove($event)"
@@ -553,7 +553,7 @@
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
@@ -567,7 +567,7 @@
</div>
<!-- Sort -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
@@ -581,7 +581,7 @@
</div>
<!-- Filter -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
@@ -597,7 +597,7 @@
</div>
</div>
<!-- Search toggle -->
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<!-- Expand/Collapse (tree only) -->
@@ -650,12 +650,12 @@
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
</div>
@@ -723,7 +723,7 @@
<span style="width:16px;flex-shrink:0;"></span>
</template>
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span class="icon" x-init="bindFileIcon($el, node)"></span>
<template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@@ -754,8 +754,8 @@
<div class="actions">
<template x-if="node.is_folder">
<span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
</span>
</template>
@@ -765,7 +765,7 @@
</div>
<!-- Children -->
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
x-init="bindChildren($el, node)">
</ul>
</li>
</template>
@@ -807,7 +807,7 @@
<tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td>
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
@@ -864,7 +864,7 @@
</tr>
<template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<td><span x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
@@ -902,7 +902,7 @@
<template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td>
@@ -933,7 +933,7 @@
<template x-for="node in displayTree" :key="'g'+node.id">
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
<div class="title-card-name">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-text="node.name"></span>
@@ -960,7 +960,7 @@
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header">
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
@@ -1023,7 +1023,7 @@
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub">
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
</p>
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
<div class="modal-actions">
@@ -1037,7 +1037,7 @@
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("edit",14) }} Rename</h3>
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
@@ -1050,7 +1050,7 @@
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("trash",16) }} Delete</h3>
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
@@ -1065,11 +1065,14 @@
{% include "_ctx_menu.html" %}
<!-- Preview tooltip -->
<div class="file-preview" x-show="previewVisible"
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
pré-existante, masquée par le fallback window d'Alpine standard) →
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
:style="{ left: previewX + 'px', top: previewY + 'px' }"
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
<div class="file-preview-header" x-text="previewName"></div>
<div class="file-preview-body" x-html="previewContent"></div>
<div class="file-preview-body" x-init="bindPreview($el)"></div>
</div>
<!-- Preview Panel — identical to Library peek-overlay -->
+1 -1
View File
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
+1 -1
View File
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
+146 -20
View File
@@ -127,7 +127,7 @@
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
<div class="settings-panel" style="position:relative;">
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
@@ -136,24 +136,24 @@
<div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
<div>
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
</div>
</template>
</div>
@@ -515,9 +515,9 @@
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 8px;">Delete tag</h3>
<p style="color:var(--text-dim);margin:0 0 16px;">
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
</span>
</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
@@ -531,7 +531,7 @@
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 12px;">Rename tag</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
<input type="text" class="settings-input" x-model="renameValue"
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
style="flex:1;" autofocus>
@@ -635,16 +635,142 @@
</div>
</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Conditions (JSON — toutes AND)</div>
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Actions (JSON)</div>
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
</div>
<!-- Pipeline visuel (steps API v7.0) -->
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Pipeline visuel (steps)</div>
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
<template x-for="(s, i) in editSteps" :key="s.id">
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
<div style="display:flex;gap:8px;align-items:center;">
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
<span style="flex:1"></span>
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
</div>
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
<template x-if="s.kind==='trigger'">
<div>
<div class="setting-label">Événement</div>
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
<datalist id="auto-events">
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
</datalist>
</div>
</template>
<template x-if="s.kind==='condition'">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
<select class="settings-input" x-model="s.config.op">
<option value="eq">=</option><option value="neq">≠</option>
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
<option value="changed">changé</option>
</select>
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
</div>
</template>
<template x-if="s.kind==='delay'">
<div>
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
</div>
</template>
<template x-if="s.kind==='action'">
<div style="display:flex;flex-direction:column;gap:6px;">
<select class="settings-input" x-model="s.config.type">
<option value="webhook">Webhook</option>
<option value="set_property">Définir une propriété</option>
<option value="create_page">Créer une page</option>
<option value="notify">Notification</option>
<option value="slack">Slack</option>
<option value="email">Email</option>
<option value="forge_issue">Issue Gitea/GitHub</option>
<option value="agent_trigger">Déclencher un agent</option>
</select>
<template x-if="s.config.type==='webhook'">
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
</template>
<template x-if="s.config.type==='set_property'">
<div style="display:flex;gap:8px;">
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
</div>
</template>
<template x-if="s.config.type==='create_page'">
<div style="display:flex;gap:8px;">
<select class="settings-input" x-model="s.config.collection_id">
<option value="">Base (optionnel)</option>
<template x-for="c in globalCollections" :key="c.id">
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
</template>
</select>
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
</div>
</template>
<template x-if="s.config.type==='notify'">
<input class="settings-input" placeholder="Message" x-model="s.config.message">
</template>
<template x-if="s.config.type==='slack'">
<div style="display:flex;gap:8px;">
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
</div>
</template>
<template x-if="s.config.type==='email'">
<div style="display:flex;gap:8px;flex-direction:column;">
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
</div>
</template>
<template x-if="s.config.type==='forge_issue'">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
</div>
</template>
<template x-if="s.config.type==='agent_trigger'">
<div style="display:flex;gap:8px;">
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
<input class="settings-input" placeholder="Message" x-model="s.config.message">
</div>
</template>
</div>
</template>
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
</div>
</div>
</template>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
<option value="trigger">Déclencheur</option>
<option value="condition">Condition</option>
<option value="delay">Attente</option>
<option value="action">Action</option>
</select>
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
@@ -770,11 +896,11 @@
<td x-text="u.folder_count" style="text-align:center;"></td>
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
<td>
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
</div>
</td>
@@ -813,7 +939,7 @@
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
<template x-for="s in ['all','api','permissions','sso']" :key="s">
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
</template>
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
@@ -837,7 +963,7 @@
<tbody>
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
<td><code style="font-size:11px;" x-text="e.action"></code></td>
@@ -853,7 +979,7 @@
</table>
</div>
<div style="display:flex;justify-content:center;margin-top:12px;">
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
</div>
<style>
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
+5
View File
@@ -100,6 +100,11 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: tableView »).
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
function tableView() {
return {
sortField: '',
+5
View File
@@ -44,6 +44,11 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: teamLoad »).
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
function teamLoad() {
return {};
}
+5
View File
@@ -63,6 +63,11 @@
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: trashData »).
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() {
return {
search: '',
+6 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
*{margin:0;padding:0;box-sizing:border-box;}
@@ -134,6 +134,11 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: onboarding »).
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+7 -2
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set page_icon = "home" %}
{% set page_title = "Workspace — Projects" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -62,7 +62,7 @@
<template x-for="p in builtinProjects" :key="p.id">
<div class="project-card" @click="openProject(p)">
<div class="project-card-top">
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
<span class="project-card-icon" x-init="bindProjectIcon($el, p)"></span>
<span class="forge-badge builtin">Built-in</span>
</div>
<div class="project-card-name" x-text="p.name"></div>
@@ -141,6 +141,11 @@
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: workspacePage »).
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
function workspacePage() {
return {
builtinProjects: [],
+3 -3
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
{% set page_icon = "home" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -159,12 +159,12 @@
</div>
<!-- Create/Rename dialog -->
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="closeCreateRename()">
<div class="dialog-box">
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
<div class="dialog-actions">
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
<button class="btn btn-secondary" @click="closeCreateRename()">Cancel</button>
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.37.0"
"version": "7.45.0"
},
"paths": {
"/auth/register": {
+40
View File
@@ -0,0 +1,40 @@
// CSP build : x-data="foo()" — globale window vs Alpine.data, lequel résout ?
const { chromium } = require('playwright-core');
const path = require('path');
const fs = require('fs');
function findChromium() {
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
dirs.sort();
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
}
const ALPINE = fs.readFileSync(
'C:/Users/bruno/AppData/Local/hermes/cache/scratch/alpine_csp.js',
'utf-8'
);
const html = `<!DOCTYPE html><html><body>
<div id="a" x-data="composantGlobal()" x-init="init()"><span id="s1" x-text="v"></span></div>
<div id="b" x-data="composantAlpineData()" x-init="init()"><span id="s2" x-text="v"></span></div>
<script>${ALPINE.replace(/<\/script>/g, '<\\/script>')}</script>
<script>
function composantGlobal() { return { v: '?', init() { this.v = 'glok'; } }; }
document.addEventListener('alpine:init', () => {
Alpine.data('composantAlpineData', () => ({ v: '?', init() { this.v = 'adok'; } }));
});
</script></body></html>`;
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
const page = await browser.newPage();
const errs = [];
page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
await page.setContent(html, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
const res = await page.evaluate(() => ({
global: document.querySelector('#s1').textContent,
alpineData: document.querySelector('#s2').textContent,
}));
console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune');
await browser.close();
})();
+237
View File
@@ -0,0 +1,237 @@
const { test, expect } = require('@playwright/test');
/**
* Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP
* d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de
* alpine.min.js via interception — SANS déployer. Toute expression que le
* parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ;
* les x-html restants = directive interdite du build (console error).
* Quand toutes les surfaces passent ici → bascule réelle + retrait
* d'unsafe-eval (ROADMAP A20 phase 3).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
const errors = [];
let currentUrl = '';
test.beforeEach(async ({ page }) => {
errors.length = 0;
currentUrl = '';
await page.route('**/static/js/alpine.min.js', (route) =>
route.fulfill({
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
contentType: 'application/javascript',
})
);
page.on('console', (m) => {
if (m.type() !== 'error') return;
if (/Failed to load resource/.test(m.text())) return;
errors.push(m.text());
});
page.on('pageerror', (e) =>
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
);
});
test.afterEach(() => expect(errors).toEqual([]));
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() === 409) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects');
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
async function assertBound(page) {
return page.evaluate(() => {
const el = document.querySelector('[x-data]');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
}
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
// les échecs RUNTIME (globales, timing de registre, scope de structure)
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
await login(page);
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
'/gitea-workspace']) {
currentUrl = url;
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
}
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const agent = await page.evaluate(() => {
const el = document.querySelector('#fd-agent-panel');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(agent).toBe('ok');
});
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
// le topbar vit dans le scope appState : les12 sites window.E ont été
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
// toute expression non parsable = pageerror (filet).
await login(page);
const coll = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/db/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'e2e-csp-editor' }),
});
return r.json();
});
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
try {
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant éditeur est lié
const editor = await page.evaluate(() => {
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
const root = document.querySelector('.app-layout');
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
const hasEd = d1 && typeof d1.edCall === 'function';
let ed = 'absent';
try {
const cand = Array.from(document.querySelectorAll('[x-data]'))
.map((e) => e.getAttribute('x-data'))
.filter((a) => a && a.startsWith('editorState'));
ed = cand.length ? 'ok' : 'aucun-editorState';
} catch (e) { ed = 'throw'; }
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
});
expect(editor.delegates).toBe('ok');
expect(editor.ed).toBe('ok');
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
// corrigé : block-set) ET leurs expressions passent le filet CSP
await expect(page.locator('.star-btn').first()).toBeAttached();
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
}, coll.id);
}
});
test('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => {
// crée une automation, ouvre l'édition, ajoute une étape (POST /steps),
// vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc)
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.click('.settings-nav-item:has-text("Automations")');
const name = 'e2e-steps-' + Date.now();
await page.fill('input[placeholder*="Notifier le statut"]', name);
await page.click('button:has-text("Créer")');
await page.waitForTimeout(700);
const row = page.locator('.setting-row', { hasText: name }).last();
await row.locator('button:has-text("✎")').click();
await page.waitForTimeout(700);
await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible();
await page.click('button:has-text("Ajouter l\'étape")');
await page.waitForFunction(
() => Array.from(document.querySelectorAll('.setting-label'))
.some((e) => (e.textContent || '').includes('Action · webhook')),
null,
{ timeout: 8000 }
);
// nettoyage API
await page.evaluate(async (n) => {
const d = await (await fetch('/workspace/automations')).json();
const a = (d.automations || []).find((x) => x.name === n);
if (a) {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/workspace/automations/' + a.id,
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {});
}
}, name);
});
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
expect(await assertBound(page)).toBe('ok');
await expect(page.locator('.settings-overlay')).toBeVisible();
});
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
expect(await assertBound(page)).toBe('ok');
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
await page.click('button.ws-icon-btn[title="Search"]');
await page.waitForTimeout(400);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
);
expect(focused).toBe(true);
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
// x-init tourne même si la rangée est masquée (x-show=filterType)
const svg = await page.evaluate(
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
);
expect(svg).toContain('<svg');
});
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant est lié par le registre Alpine.data (scope CSP)
expect(await assertBound(page)).toBe('ok');
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
const svg = await page.evaluate(
() => document.querySelector('#lib-empty .empty-icon').innerHTML
);
expect(svg).toContain('<svg');
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
await page.click('.lib-icon-btn[title="Search"]');
await expect(page.locator('#lib-search-input')).toBeVisible();
await page.waitForTimeout(300);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.id === 'lib-search-input'
);
expect(focused).toBe(true);
});
File diff suppressed because one or more lines are too long
+19
View File
@@ -164,4 +164,23 @@ test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
return !ov || !ov.classList.contains('open');
});
expect(closed).toBe(true);
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
await page.keyboard.press('Control+k');
await page.waitForTimeout(300);
await page.click('.cp-tab[data-tab="ai"]');
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
await page.waitForFunction(
() => {
const el = document.querySelector('.cmd-palette-ai');
return el && !el.querySelector('.cp-loading');
},
null,
{ timeout: 15000 }
);
const ai = await page.evaluate(() => {
const el = document.querySelector('.cmd-palette-ai');
return { text: ((el && el.textContent) || '').trim() };
});
expect(ai.text.length).toBeGreaterThan(5);
});
+2
View File
@@ -40,6 +40,8 @@ const browserGlobals = {
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
// Globals exposed on window by app.js
openModal: "readonly", closeModal: "readonly",
// getCsrf : helper unique posé dans le <head> de base.html (A38 phase 1)
getCsrf: "readonly",
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
// TextDecoder : API navigateur (ES2015)
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
+4
View File
@@ -21,6 +21,10 @@
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
addTagAndClear(value, color, el){
this.addNewTag(value, color);
if (el) el.value = '';
},
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
+5
View File
@@ -265,6 +265,10 @@
});
}).catch(function(){});
},
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
bindMarkdown(el, m){
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
},
approveProposal(m){
if(!m || m.applied || m.offline) return;
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
@@ -390,6 +394,7 @@
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
},
showHistory(){ this.tab = 'history'; this.loadConversations(); },
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
+23
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,5 +1,6 @@
// FlowDeck Notion UI — Client-side logic
// Alpine.js + SortableJS + HTMX + Mobile support
/* exported openCardDetail */ /* global owner, repo */
// ── Frontend Error Capture ──────────────────────────────────
// Intercepte TOUTES les erreurs JS et les envoie au backend.
+25 -2
View File
@@ -18,6 +18,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
el.classList.add('active');
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: kanbanBoard »).
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
function kanbanBoard() {
return {
collapsedGroups: [],
@@ -33,6 +38,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: filterSystem »).
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
function filterSystem() {
return {
activeFilters: [],
@@ -60,6 +70,9 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
removeFilterAndRefresh(i) { this.removeFilter(i); this.refreshView(); },
resetFiltersAndRefresh() { this.resetFilters(); this.refreshView(); },
pickStatus(v) { this.toggleStatus(v); this.showStatusMenu = false; this.refreshView(); },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
@@ -67,6 +80,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: sortSystem »).
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
function sortSystem() {
return {
sorts: [],
@@ -89,12 +107,17 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: newIssueForm »).
document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = getCsrf();;
const csrf = getCsrf();
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf }
@@ -131,7 +154,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const toStatus = evt.to.dataset.status;
const csrf = getCsrf();;
const csrf = getCsrf();
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf }
+14
View File
@@ -367,6 +367,20 @@ document.addEventListener('alpine:init', () => {
this.loadSidebarTree();
},
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
closePrivateEdit() {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
fmtGwDate(pp) {
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
},
bindGwIcon(el, item) {
Alpine.effect(() => {
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
});
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
+5
View File
@@ -1,5 +1,10 @@
/* exported importWizard -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: importWizard »).
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
function importWizard() {
return {
sources: [],
+43 -2
View File
@@ -1,5 +1,11 @@
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
// registre (probe : globale window → « Undefined variable: libraryPage »).
document.addEventListener('alpine:init', function () {
Alpine.data('libraryPage', libraryPage);
});
function libraryPage() {
return {
tab: 'recents',
@@ -160,7 +166,42 @@ function libraryPage() {
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(item) {
openMoveSelected() {
this.openMovePicker(Object.keys(this.selected).map(Number));
},
// ── A20 phase 3 (surface library) : formes compatibles build CSP ──
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
// réactivité via Alpine.effect — valable sous les deux builds.
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.sortOpen = false;
this.viewOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.filterOpen = false;
this.viewOpen = false;
},
toggleViewMenu() {
this.viewOpen = !this.viewOpen;
this.filterOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
var el = document.getElementById('lib-search-input');
if (el) el.focus();
});
},
bindHtmlIcon(el) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(this.emptyIcon, 48); });
},
bindHtmlItem(el, item) {
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
},
_renderIcon(item) {
if (!item) return getSvgIcon('file', 14);
// Custom emoji (image URL), icon name, or unicode emoji
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
@@ -363,7 +404,7 @@ function libraryPage() {
},
_getCsrf() {
var m = getCsrf();;
var m = getCsrf();
return m;
},
+79 -6
View File
@@ -1,7 +1,8 @@
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
const _wsInitData = (function() { // lexical : NON propriété globalThis →
// invisible au snapshot ji du build CSP (valeur bannie sinon)
return {
tree:[],
displayTree:[],
@@ -2007,13 +2008,85 @@ window._wsInitData = (function() {
this.clearSelection();
this._reloadAfterAction();
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
}
},
};
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
// (réactivité conservée : lecture des données réactives dans l'effect) ──
toggleViewMenu() {
this.viewMenuOpen = !this.viewMenuOpen;
this.sortOpen = false;
this.searchOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.viewMenuOpen = false;
},
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.viewMenuOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
const el = document.querySelector('[x-ref="searchInput"]');
if (el) el.focus();
});
},
createPageAt(node) { window.FlowDeck.createPage(node.id); },
createFolderAt(node) { window.FlowDeck.showCreateFolderModal(node.id); },
bindSvg(el, name, size) { el.innerHTML = getSvgIcon(name, size); },
bindFileIcon(el, node) {
Alpine.effect(() => {
el.innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
});
},
bindNodeIcon(el, node) {
Alpine.effect(() => {
el.innerHTML = node.is_folder
? getSvgIcon('folder', 24)
: this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
});
},
bindChildren(el, node) {
Alpine.effect(() => {
el.innerHTML = this.renderChildren(node.children, (node.depth || 0) + 1, this.tagsVersion);
});
},
bindPreview(el) { Alpine.effect(() => { el.innerHTML = this.previewContent; }); },
};
})();
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
var _wsKeys = Object.keys(window._wsInitData);
var _wsKeys = Object.keys(_wsInitData);
for (var _i = 0; _i < _wsKeys.length; _i++) {
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
try { window[_wsKeys[_i]] = _wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
}
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsInitData).length);
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
document.addEventListener('alpine:init', function () {
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
Alpine.data('wsInitData', function () { return _wsInitData; });
// Bloc preview : le parseur le place HORS de la div racine (structure
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
// qu'une fois) + lecture/écriture via Alpine.reactive(_wsInitData) pour
// garder la réactivité JS↔Alpine.
Alpine.data('wsPreview', function () {
const R = Alpine.reactive(_wsInitData);
const t = {};
['previewVisible', 'previewX', 'previewY', 'previewName'].forEach(function (k) {
Object.defineProperty(t, k, {
get: function () { return R[k]; },
set: function (v) { R[k] = v; },
configurable: true,
});
});
t.bindPreview = function (el) {
Alpine.effect(function () { el.innerHTML = R.previewContent; });
};
return t;
});
});
+24
View File
@@ -1125,6 +1125,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
wrap.classList.toggle('full-width',!!this.fullWidth);
wrap.classList.toggle('small-text',!!this.fontSmall);
},
closeInviteSuggest(){ this.inviteSuggestOpen = false; this.inviteUsers = []; },
setSharePerm(a, perm){ this.updateShare(a.id, perm); a.permOpen = false; },
removeShareAndClose(a){ this.removeShare(a.id); a.permOpen = false; },
pickGeneralAccess(v){ this.generalAccess = v; this.accessMenuOpen = false; },
applyIcon(){ this.setIcon(this.iconValue); this.iconOpen = false; },
moreToggleLock(){ this.moreOpen = false; this.toggleLock(); },
moreFullWidth(){ this.moreOpen = false; this.setPageOption('full_width', !this.fullWidth); },
moreFontSmall(){ this.moreOpen = false; this.setPageOption('font_small', !this.fontSmall); },
moreSaveTemplate(){ this.moreOpen = false; this.saveAsTemplate(); },
markAndSave(){ this.dirty = true; this.save(); },
async toggleLock(){
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
const next=!this.isLocked;
@@ -1421,6 +1431,15 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
}catch(e){this.showToast('Remove failed','error');}
},
toggleIcon(){this.iconOpen=!this.iconOpen;},
openBacklink(b){
this.backlinksOpen = false;
window.location.href = '/pages/' + b.id;
},
fmtImportSize(f){ return Math.round(f.size / 1024) + ' KB'; },
// A20 ph3 : x-html interdit sous build CSP → effet réactif
bindIconHtml(el){
Alpine.effect(() => { el.innerHTML = this.iconHtml(); });
},
iconHtml(){
var v=this.iconValue;
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
@@ -2512,5 +2531,10 @@ applyAIBlocks(text){
default: return c;
}
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: editorState »).
document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
window.editorState = editorState;
}
+141
View File
@@ -1,8 +1,20 @@
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: settingsInit »).
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
function settingsInit() {
return {
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
historyBack() { window.history.back(); },
fmtLastLogin(u) {
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
},
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
activeSection: 'account',
allTags: [],
newTagName: '',
@@ -63,6 +75,9 @@ function settingsInit() {
automations: [],
autoRuns: [],
editAutomationId: null,
editSteps: [],
stepEdit: -1,
stepNewKind: 'action',
globalCollections: [],
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
@@ -761,6 +776,128 @@ function settingsInit() {
if (r.ok) { this.githubLinked = false; }
} catch { /* volontaire */ }
},
// ── Éditeur visuel de steps (API v7.0 : /steps CRUD) ──
async loadSteps() {
if (!this.editAutomationId) { this.editSteps = []; return; }
try {
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps',
{credentials:'same-origin'});
var d = await r.json();
this.editSteps = Array.isArray(d) ? d : (d.steps || []);
} catch { this.editSteps = []; }
},
stepSummary(s) {
var c = s.config || {};
if (s.kind === 'trigger') return '\ud83d\udce1 D\u00e9clencheur \u00b7 ' + (c.event || '?');
if (s.kind === 'condition') return '\u2696 Condition \u00b7 ' + (c.property || '?') + ' ' + (c.op || 'eq') + ' ' + (c.value || '');
if (s.kind === 'delay') return '\u23f3 Attente \u00b7 ' + (c.seconds || 0) + ' s';
var extra = c.url || c.message || c.text || c.to || (c.owner ? c.owner + '/' + c.repo : '') || c.title || '';
return '\u26a1 Action \u00b7 ' + (c.type || '?') + (extra ? ' \u2192 ' + String(extra).slice(0, 60) : '');
},
defaultStepConfig(kind) {
if (kind === 'trigger') return { event: 'page.created' };
if (kind === 'condition') return { property: '', op: 'eq', value: '' };
if (kind === 'delay') return { seconds: 60 };
return { type: 'webhook', url: '' };
},
async addStep() {
if (!this.editAutomationId) return;
try {
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps', {
method: 'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({ kind: this.stepNewKind, config: this.defaultStepConfig(this.stepNewKind) })
});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
await this.loadSteps();
this.stepEdit = this.editSteps.length - 1;
} catch { /* volontaire */ }
},
async saveStep(s) {
var cfg = s.config || {};
// forge_issue : labels en cha\u00eene s\u00e9par\u00e9e par virgules → liste
if (cfg.type === 'forge_issue' && typeof cfg.labels === 'string') {
cfg.labels = cfg.labels.split(',').map(function(x){ return x.trim(); }).filter(Boolean);
}
try {
var r = await fetch('/workspace/automations/steps/' + s.id, {
method:'PUT', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({ kind: s.kind, config: cfg })
});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
window.showToast && window.showToast('\u00c9tape enregistr\u00e9e');
await this.loadSteps();
} catch { /* volontaire */ }
},
async delStep(i) {
var s = this.editSteps[i]; if (!s) return;
try {
await fetch('/workspace/automations/steps/' + s.id, {
method:'DELETE', credentials:'same-origin',
headers:{'X-CSRF-Token': this.getCsrfToken()} });
this.stepEdit = -1;
await this.loadSteps();
} catch { /* volontaire */ }
},
async moveStep(i, dir) {
var j = i + dir;
if (j < 0 || j >= this.editSteps.length) return;
var a = this.editSteps[i], b = this.editSteps[j];
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
try {
await fetch('/workspace/automations/steps/' + a.id, {method:'PUT', headers:h,
credentials:'same-origin', body: JSON.stringify({position: b.position})});
await fetch('/workspace/automations/steps/' + b.id, {method:'PUT', headers:h,
credentials:'same-origin', body: JSON.stringify({position: a.position})});
await this.loadSteps();
} catch { /* volontaire */ }
},
async convertToSteps() {
// JSON legacy → pipeline ordonn\u00e9 (trigger, conditions, actions)
if (!this.editAutomationId) return;
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
var base = '/workspace/automations/' + this.editAutomationId + '/steps';
var order = [];
if (this.af.trigger_type !== 'cron') {
var tcfg = {event: this.af.event || 'page.created'};
if (this.af.collection_id) tcfg.collection_id = parseInt(this.af.collection_id, 10);
order.push({kind:'trigger', config: tcfg});
}
try {
JSON.parse(this.af.condition_json || '[]').forEach(function(c){ order.push({kind:'condition', config:c}); });
} catch { /* volontaire */ }
try {
JSON.parse(this.af.actions_json || '[]').forEach(function(a){ order.push({kind:'action', config:a}); });
} catch { /* volontaire */ }
for (var i = 0; i < order.length; i++) {
var r = await fetch(base, {method:'POST', headers:h, credentials:'same-origin',
body: JSON.stringify(order[i])});
if (!r.ok) {
var d = await r.json().catch(function(){ return {}; });
window.showToast && window.showToast(d.detail || 'Conversion \u00e9chou\u00e9e', 'error');
break;
}
}
await this.loadSteps();
},
navTo(sec) {
this.activeSection = sec;
var loaders = {'api-tokens':'loadApiTokens', 'sessions':'loadSessions',
'extensions':'loadClipperDevices', 'automations':'loadAutomations',
'admin-users':'loadAdminUsers', 'admin-audit':'loadAdminAudit',
'admin-backups':'loadBackups', 'admin-sso':'loadSsoConfig'};
if (loaders[sec] && this[loaders[sec]]) this[loaders[sec]]();
},
setAuditSource(s) { this.auditSource = s; this.loadAuditLogs(); },
auditNext() { this.auditOffset += this.auditPageSize; this.loadAuditLogs(false); },
editUserRow(u) {
this.editingUser = u;
this.editUserForm = { login: u.login, name: u.full_name, email: u.email,
is_admin: u.is_admin, is_active: u.is_active };
},
async loadAutomations() {
try { await this.loadCollectionsForForm(); } catch { /* volontaire */ }
try {
@@ -814,9 +951,13 @@ function settingsInit() {
condition_json: a.condition_json || '[]',
actions_json: a.actions_json || '[]'
};
this.stepEdit = -1;
this.loadSteps();
},
cancelEditAutomation() {
this.editAutomationId = null;
this.editSteps = [];
this.stepEdit = -1;
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
},
async toggleAutomation(id, currentlyEnabled) {
+6
View File
@@ -1,11 +1,17 @@
/* exported workspacesPage -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: workspacesPage »).
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
closeCreateRename() { this.showCreate = false; this.showRename = false; },
wsName: '',
renameTarget: null,
+2 -2
View File
@@ -8,7 +8,7 @@
═══════════════════════════════════════════════════════════ */
'use strict';
const CACHE_NAME = 'flowdeck-v7'; // v7 : A20 (htmx allowEval off, Inter auto-hébergé)
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
const DATA_CACHE = 'flowdeck-data-v7';
// App shell (assets versionnés comme référencés dans les templates).
@@ -23,7 +23,7 @@ const PRECACHE_URLS = [
'/static/css/prism.css',
// JS
'/static/js/htmx.min.js',
'/static/js/alpine.min.js',
'/static/js/alpine.csp.min.js',
'/static/js/sortable.min.js',
'/static/js/app.js?v=2.4.8',
'/static/js/offline.js?v=6.0.0',
+2 -2
View File
@@ -226,8 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
assert nm, script_src
nonce = nm.group(1)
assert "'unsafe-inline'" not in script_src, script_src
assert "'unsafe-eval'" in script_src # reste A20 phase 3 (Alpine standard,
# build CSP bloqué : 13 exprs non parsables + 24 x-html réactifs → ROADMAP)
# A20 TERMINÉ : Alpine en build CSP (alpine.csp.min.js) + htmx allowEval=false
assert "'unsafe-eval'" not in script_src
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)
+22
View File
@@ -0,0 +1,22 @@
"""Les `right_actions` du topbar doivent être servis PARSED (A10).
Regresssion historique : `'literal' ~ fd_icon(...)` — fd_icon = macro →
Markup, et `Markup.__radd__/__add__` échappe les segments littéraux →
boutons livrés en entities (`&#34;`) sur TOUTES les pages. Fix : block-set
`{% set x %}…{{ fd_icon() }}…{% endset %}` (5 templates).
"""
from tests.conftest import login_test_client
def test_right_actions_parsed(client):
login_test_client(client)
r = client.get("/workspaces")
assert r.status_code == 200
body = r.text
parsed = 'class="topbar-btn"' in body
escaped = "&#34;topbar-btn&#34;" in body
print("PARSE:", parsed, "| ESCAPED:", escaped)
print("DBG present:", "DBGCLS" in body)
k = body.find("DBGCLS=")
print("CTX:", body[max(0, k - 80) : k + 320].replace("\n", " ") if k >= 0 else "pas de DBG")
assert parsed and not escaped, "right_actions servi échappé"