diff --git a/CHANGELOG.md b/CHANGELOG.md index 44daba4..704931e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog - FlowDeck +## v7.45.5 (2026-10-03) — Poubelle : actions groupées, tri/filtres réels, purge, et 4 bugs + +### Added + +- **Sélection multiple** sur /trash : case « Select all » (portée au filtre + courant), barre d'actions groupées **Restore / Delete / Clear** avec compteur, + et compteur de résultats (`N pages` / `N of M pages`). +- **Empty Trash** : purge totale en un clic (confirm) via le nouveau + `POST /board/api/trash/empty` — traitement en masse côté serveur (une seule + requête, pas N appels). +- **Tri** : Recently deleted (défaut) / Oldest first / Page name A → Z — remplace + le bouton décoratif « Last edited by ▾ » qui ne faisait rien. +- **Filtre par emplacement** réel (`In ▾` décoratif remplacé) : liste déroulante + construite depuis les workspaces présents dans la poubelle, avec état actif. +- **Dates par élément** : « Deleted 3 days ago · 27 days left » (rétention + 30 j alignée sur `app/services/trash.py`), horodatages UTC ou ISO gérés. +- **États vides distincts** : poubelle vide vs aucun résultat pour les filtres + (avec bouton « Clear filters »), et **toasts** sur chaque action (succès et + échec) au lieu d'échecs silencieux. + +### Fixed + +- **Les boutons Restore / Delete ne marchaient JAMAIS** : `getCsrf()` renvoie la + CHAÎNE du jeton mais le code faisait `csrf?.[1]` → 2ᵉ caractère → 403 CSRF + systématique, avalé par `if (r.ok)`. Corrigé + toasts d'erreur. +- **« Move to Trash » depuis l'éditeur envoyait une 404** : la route appelée + (`/board/api/pages/{id}/trash`) n'existe pas — route réelle + `/api/pages/{id}/trash` — et le `.then()` naviguait quand même, donc la page + partait à l'accueil SANS être mise à la poubelle. URL corrigée + on ne + navigue plus si la réponse n'est pas ok. +- **Une page restaurée disparaissait de Library / Recents / Private** : la + suppression depuis l'éditeur réécrivait `parent_section='Trash'` et la + restauration ne le remettait pas, alors que tous les listings filtrent + `parent_section != 'Trash'`. `deleted_at` est maintenant la seule source de + vérité (écriture retirée) + réparation au boot des lignes déjà cassées + (`db.init_db`, idempotente). +- **Routes trash sans aucune authentification** : `GET /board/api/trash`, + `restore`, `delete` et `POST /api/pages/{id}/trash` ne vérifiaient rien (le + CSRF ne protège pas : cookie lisible + en-tête forgé par l'attaquant) → 401 + obligatoire, comme les routes sœurs du même fichier. Vérifié anonyme : 401. +- Le sidebar de /trash listait `parent_section='Trash'` → aligné sur + `deleted_at IS NOT NULL`. + +### Tests + +- `tests/test_trash_api.py` (5 tests) : auth 401, soft-delete sans marqueur + `Trash`, restauration, suppression définitive, purge en masse (ne touche pas + aux pages vivantes), réparation au boot. +- `e2e/trash_ui.spec.js` : rendu, dates, sélection groupée, restauration + unitaire (le vrai test du fix CSRF), purge — noms uniques par exécution et + garde « on ne vide jamais la poubelle d'autrui ». +- `test_smoke_uncovered.py` : l'assertion qui codait le bug + (`parent_section == 'Trash'`) corrigée. + ## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 7f2548f..fe4038f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1007,6 +1007,7 @@ Détails livrés : - [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift). - Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément). - Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé. +- [x] **Poubelle** (v7.45.5) — page /trash alignée sur ce que fait ce type de section : **sélection multiple** (select all porté au filtre courant + barre Restore/Delete/Clear), **Empty Trash** (`POST /board/api/trash/empty`, purge en masse en 1 requête), **tri** (récents/anciens/nom) et **filtre emplacement** réels (les deux boutons décoratifs « Last edited by ▾ » / « In ▾ » qui ne faisaient rien sont remplacés), **date de suppression + jours restants** par élément (rétention 30 j), compteur de résultats, états vides distincts, toasts sur chaque action. 4 bugs trouvés en route : (1) **Restore/Delete ne marchaient jamais** (`getCsrf()` = chaîne mais code lu `csrf?.[1]` → 403 CSRF silencieux), (2) **« Move to Trash » de l'éditeur = 404** (route `/board/api/pages/{id}/trash` inexistante, `.then()` naviguait quand même → la page n'allait jamais à la poubelle), (3) **page restaurée invisible** en Library/Recents/Private (`parent_section='Trash'` réécrit au delete, jamais remis → `deleted_at` = source de vérité unique + réparation au boot), (4) **routes trash sans auth** (401 ajouté, vérifié anonyme). Gates : `tests/test_trash_api.py` (5) + `e2e/trash_ui.spec.js` (1) + suite 1099 passed. ### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28) > **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents). diff --git a/VERSION b/VERSION index 2ae0c66..51db024 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.45.4 +7.45.5 diff --git a/WORKLOAD.md b/WORKLOAD.md index e3954ad..0f3efa1 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.45.4 (anti-FOUC : `x-cloak` sur `.app-layout` = plus de sidebar brute ni de bande rouge hors ligne au chargement complet, classe morte `fd-navigating` supprimée, probe étendu à 4 scénarios avec assertions) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.45.5 (poubelle : sélection groupée + Empty Trash + tri/filtres réels + dates par élément ; fix CSRF restore/delete, fix 404 « Move to Trash » éditeur, fix pages restaurées invisibles, auth sur les routes trash) | **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 diff --git a/app/db.py b/app/db.py index b4f9fde..0024c5b 100644 --- a/app/db.py +++ b/app/db.py @@ -348,6 +348,16 @@ def init_db(): conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT") except sqlite3.OperationalError: pass + # v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien + # soft-delete (éditeur) réécrivait parent_section='Trash' et la + # restauration ne le remettait pas → page invisible en Library/Recents/ + # Private. Idempotent (WHERE restrictif), rejoué à chaque boot. + # ponytail: un dossier restauré repasse en 'Private' (l'état d'origine + # n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin. + conn.execute( + "UPDATE pages SET parent_section='Private' " + "WHERE parent_section='Trash' AND deleted_at IS NULL" + ) try: conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'") except sqlite3.OperationalError: diff --git a/app/main.py b/app/main.py index 49df1e8..156590c 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.45.4", + version="7.45.5", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/board/sharing.py b/app/routers/board/sharing.py index 9d2c6f5..8e23e2b 100644 --- a/app/routers/board/sharing.py +++ b/app/routers/board/sharing.py @@ -125,21 +125,41 @@ def unpublish_page(request: Request, page_id: int): # ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ +def _trash_session(request: Request): + """Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les + pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est + lisible par JS et un attaquant fixe cookie ET en-tête).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + raise HTTPException(401, "Authentication required") + return user -# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ - @router.get("/api/trash") def list_trash(request: Request): + _trash_session(request) with get_conn() as conn: - rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() - return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] + rows = conn.execute( + "SELECT id, title, workspace, parent_section, deleted_at FROM pages " + "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" + ).fetchall() + return [ + { + "id": r["id"], + "name": r["title"] or "Untitled", + "icon": "📁" if r["parent_section"] == "Workspace" else "📄", + "path": r["workspace"] or "Private", + "deleted_at": r["deleted_at"], + } + for r in rows + ] @router.post("/api/trash/{page_id}/restore") def restore_page(request: Request, page_id: int): + _trash_session(request) with get_conn() as conn: conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) conn.commit() @@ -154,6 +174,7 @@ def restore_page(request: Request, page_id: int): @router.delete("/api/trash/{page_id}") def permanent_delete(request: Request, page_id: int): + _trash_session(request) with get_conn() as conn: conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) @@ -163,6 +184,26 @@ def permanent_delete(request: Request, page_id: int): +@router.post("/api/trash/empty") +def empty_trash(request: Request): + """Empty Trash : purge en masse (les enfants des pages supprimées passent + en racine, comme permanent_delete — comportement historique conservé).""" + _trash_session(request) + with get_conn() as conn: + n = conn.execute( + "SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL" + ).fetchone()[0] + conn.execute( + "UPDATE pages SET parent_id=NULL WHERE parent_id IN " + "(SELECT id FROM pages WHERE deleted_at IS NOT NULL)" + ) + conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL") + conn.commit() + return {"status": "ok", "deleted": n} + + + + @router.get("/trash", response_class=HTMLResponse) def trash_page(request: Request): from app.templating import ENV diff --git a/app/routers/dashboard/pages_api.py b/app/routers/dashboard/pages_api.py index c5732b0..1e65811 100644 --- a/app/routers/dashboard/pages_api.py +++ b/app/routers/dashboard/pages_api.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from fastapi import APIRouter, Body, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager @@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={} @router.post("/api/pages/{page_id:int}/trash") -def api_trash_page(page_id: int): - """Soft-delete a page (move to trash).""" +def api_trash_page(page_id: int, request: Request): + """Soft-delete a page (move to trash). + + v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at`` + est la seule source de vérité. L'ancienne écriture marquait définitivement + la page : à la restauration elle restait 'Trash' et disparaissait des + listings Library / Recents / Private (``parent_section != 'Trash'``). + """ + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + raise HTTPException(401, "Authentication required") with get_conn() as conn: conn.execute( - "UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?", + "UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (page_id,), ) conn.commit() diff --git a/app/routers/dashboard/pages_html.py b/app/routers/dashboard/pages_html.py index fc91d7d..082f992 100644 --- a/app/routers/dashboard/pages_html.py +++ b/app/routers/dashboard/pages_html.py @@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que sidebar = board_sidebar(request, owner, repo) with get_conn() as conn: ws_key = f"{owner}/{repo}" if owner and repo else "" - # Pages are soft-deleted via parent_section='Trash' + # Pages are soft-deleted via deleted_at (parent_section n'est plus + # touché par le trash → source de vérité unique, v7.45.5) if ws_key: rows = conn.execute( - "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", + "SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC", (ws_key,), ).fetchall() else: rows = conn.execute( - "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", + "SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC", ).fetchall() sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] # Pass active workspace for breadcrumb nav menu diff --git a/app/templates/trash.html b/app/templates/trash.html index cad4747..7eefe5a 100644 --- a/app/templates/trash.html +++ b/app/templates/trash.html @@ -12,45 +12,93 @@
- -
- {{ fd_icon("search",14) }} - + +
+
+ {{ fd_icon("search",14) }} + +
+ + +
+ +
+
Sort by
+
Recently deleted
+
Oldest first
+
Page name A → Z
+
+
+ + +
+ +
+
Location
+
All locations
+ +
+
+ + +
- -
- - + +
+ + +
+ + + +
-
-
{{ fd_icon("trash",14) }}
+ + +
+
{{ fd_icon("trash",24) }}

Trash is empty

Deleted pages will appear here for 30 days.

+ + +
+
{{ fd_icon("search",24) }}
+

No pages match

+

+ +
@@ -64,37 +112,164 @@ {% block scripts %}