feat: poubelle flexible (sélection groupée, purge, tri/filtres, dates) + 4 bugs (v7.45.5)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / test (push) Successful in 15m21s

Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
  groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
  remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
  ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
  app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
  Clear filters), toasts sur chaque action.

Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
   du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
   avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
   /board/api/pages/{id}/trash (inexistante) alors que la route réelle est
   /api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
   à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
   réécrivait parent_section='Trash' et la restauration ne le remettait pas,
   alors que tous les listings filtrent parent_section != 'Trash'. Écriture
   retirée (deleted_at = source de vérité unique) + réparation idempotente au
   boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
   lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
   POST /api/pages/{id}/trash — vérifié anonyme → 401.

Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
This commit is contained in:
2026-10-03 01:35:26 -04:00
parent aa88cf6665
commit 6582df3bcb
15 changed files with 623 additions and 51 deletions
+54
View File
@@ -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
+1
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
7.45.4
7.45.5
+1 -1
View File
@@ -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
+10
View File
@@ -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:
+1 -1
View File
@@ -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,
+45 -4
View File
@@ -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
+13 -4
View File
@@ -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()
+4 -3
View File
@@ -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
+208 -33
View File
@@ -12,45 +12,93 @@
</div>
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
<!-- Search -->
<div style="position:relative; margin-bottom:12px;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
<!-- Toolbar : recherche + tri + emplacement + purge -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
<div style="position:relative; flex:1;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
</div>
<!-- Tri -->
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
</div>
</div>
<!-- Emplacement -->
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
{{ fd_icon("folder",16) }}
</button>
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
<div class="dd-label">Location</div>
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
<template x-for="l in locations" :key="l">
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
</template>
</div>
</div>
<!-- Purge totale -->
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
</div>
<!-- Filters -->
<div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="trash-filter active">
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
</button>
<button class="trash-filter">
<span>{{ fd_icon("folder",14) }}</span> In ▾
</button>
<!-- Sélection + compteur + actions groupées -->
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
</label>
<span x-text="countLabel"></span>
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
<button class="btn-sm" @click="restoreSelected()">Restore</button>
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
<button class="btn-sm" @click="clearSel()">Clear</button>
</div>
</div>
<!-- Items -->
<div style="min-height:200px;">
<template x-for="item in filteredItems" :key="item.id">
<div class="trash-item">
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
:checked="selected.includes(item.id)" @change="toggle(item.id)">
<span class="trash-item-icon" x-text="item.icon"></span>
<div class="trash-item-info">
<span class="trash-item-name" x-text="item.name"></span>
<span class="trash-item-path" x-text="item.path"></span>
<span class="trash-item-path">
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
</span>
</div>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
↩️
</button>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
{{ fd_icon("trash",14) }}
</button>
</div>
</template>
<div x-show="filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
<!-- État vide : aucun contenu -->
<div x-show="items.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
<h3>Trash is empty</h3>
<p>Deleted pages will appear here for 30 days.</p>
</div>
<!-- État vide : filtres sans résultat -->
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
<h3>No pages match</h3>
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
</div>
</div>
<!-- Info -->
@@ -64,37 +112,164 @@
{% 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 »).
if (window.Alpine) { Alpine.data('trashData', trashData); }
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() {
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
var RETENTION_DAYS = 30;
return {
search: '',
sort: 'recent',
locFilter: 'all',
sortOpen: false,
locOpen: false,
selected: [],
items: [],
async init() {
const csrf = getCsrf();;
const token = csrf;
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.items = await r.json();
} catch(e) { this.items = []; }
} catch (e) {
this.items = [];
window.showToast('Could not load the Trash: ' + e.message, 'error');
}
},
get locations() {
const set = {};
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
return Object.keys(set).sort();
},
get filteredItems() {
const q = this.search.toLowerCase();
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
const loc = this.locFilter;
const out = this.items.filter(function (i) {
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
});
const key = function (i) { return i.deleted_at || ''; };
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
return out;
},
get countLabel() {
const n = this.filteredItems.length, t = this.items.length;
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
},
get allSelected() {
const list = this.filteredItems;
const self = this;
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
},
toggle(id) {
const i = this.selected.indexOf(id);
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
},
toggleAll() {
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
},
clearSel() { this.selected = []; },
clearFilters() { this.search = ''; this.locFilter = 'all'; },
toggleSort() { this.sortOpen = !this.sortOpen; },
toggleLoc() { this.locOpen = !this.locOpen; },
setSort(v) { this.sort = v; this.sortOpen = false; },
setLoc(v) { this.locFilter = v; this.locOpen = false; },
// ── API ──────────────────────────────────────────────────────────
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
_drop(ids) {
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
},
async _restore(id) {
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
if (r.ok) this._drop([id]);
return r.ok;
},
async _delete(id) {
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
if (r.ok) this._drop([id]);
return r.ok;
},
async restore(id) {
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
try {
if (await this._restore(id)) window.showToast('Page restored', 'success');
else window.showToast('Restore failed', 'error');
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
try {
if (await this._delete(id)) window.showToast('Page permanently deleted', 'success');
else window.showToast('Delete failed', 'error');
} catch (e) { window.showToast('Delete failed: ' + e.message, 'error'); }
},
async restoreSelected() {
const ids = this.selected.slice();
let ok = 0;
for (const id of ids) {
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
}
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
},
async deleteSelected() {
const ids = this.selected.slice();
if (!confirm('Permanently delete ' + ids.length + ' page(s)? This cannot be undone.')) return;
let ok = 0;
for (const id of ids) {
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
}
window.showToast(ok + ' of ' + ids.length + ' page(s) deleted', ok === ids.length ? 'success' : 'error');
},
async emptyTrash() {
const n = this.items.length;
if (!confirm('Permanently delete ALL ' + n + ' page(s) in the Trash? This cannot be undone.')) return;
try {
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.items = [];
this.selected = [];
window.showToast(n + ' page(s) purged', 'success');
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
},
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
_ts(s) {
if (!s) return null;
let t = String(s).replace(' ', 'T');
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
},
ago(s) {
const d = this._ts(s);
if (!d) return 'Deleted';
const sec = (Date.now() - d.getTime()) / 1000;
if (sec < 60) return 'Deleted just now';
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
const days = Math.floor(sec / 86400);
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
},
leftLabel(s) {
const d = this._ts(s);
if (!d) return '';
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
if (left <= 0) return ' · auto-delete soon';
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
}
};
}
+22 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.45.4"
"version": "7.45.5"
},
"paths": {
"/auth/register": {
@@ -3218,7 +3218,7 @@
"dashboard"
],
"summary": "Api Trash Page",
"description": "Soft-delete a page (move to trash).",
"description": "Soft-delete a page (move to trash).\n\nv7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``\nest la seule source de vérité. L'ancienne écriture marquait définitivement\nla page : à la restauration elle restait 'Trash' et disparaissait des\nlistings Library / Recents / Private (``parent_section != 'Trash'``).",
"operationId": "api_trash_page_api_pages__page_id__trash_post",
"parameters": [
{
@@ -4082,6 +4082,26 @@
}
}
},
"/board/api/trash/empty": {
"post": {
"tags": [
"board"
],
"summary": "Empty Trash",
"description": "Empty Trash : purge en masse (les enfants des pages supprimées passent\nen racine, comme permanent_delete — comportement historique conservé).",
"operationId": "empty_trash_board_api_trash_empty_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/board/trash": {
"get": {
"tags": [
+131
View File
@@ -0,0 +1,131 @@
/**
* Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge.
*
* Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors
* que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient
* pas. Ici on clique pour de vrai et on vérifie l'état via l'API.
*/
const { test, expect } = require('@playwright/test');
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';
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: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
// Appel API depuis la page (cookies + jeton CSRF du document).
async function api(page, method, url) {
return page.evaluate(async ([m, u]) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } });
return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) };
}, [method, url]);
}
async function mkTrashed(page, name) {
const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name));
expect(c.ok, 'create page').toBeTruthy();
const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash');
expect(t.ok, 'soft delete').toBeTruthy();
return c.body.id;
}
const trashIds = async (page) => {
const r = await api(page, 'GET', '/board/api/trash');
expect(r.ok).toBeTruthy();
return (r.body || []).map((i) => i.id);
};
test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => {
test.setTimeout(90000);
const errs = [];
page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160)));
page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); });
page.on('dialog', (d) => d.accept()); // confirm() des actions destructives
await login(page);
// DB persistante : nommage unique par exécution, on ne dépend jamais des
// restes des runs précédents.
const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase();
const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D';
const idA = await mkTrashed(page, nameA);
const idB = await mkTrashed(page, nameB);
await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' });
await page.waitForTimeout(1000);
// On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance
// de test peut contenir d'autres éléments (jamais on ne touche aux leurs).
await page.fill('input[placeholder="Search pages in Trash"]', TAG);
await page.waitForTimeout(400);
const rowA = page.locator('.trash-item', { hasText: nameA });
const rowB = page.locator('.trash-item', { hasText: nameB });
await expect(rowA).toHaveCount(1);
await expect(rowB).toHaveCount(1);
// Date de suppression + jours restants affichés (standard « trash »)
await expect(rowA.first()).toContainText('Deleted');
await expect(rowA.first()).toContainText('left');
// Compteur + purge totale présents
await expect(page.locator('label input[type=checkbox]')).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible();
// Sélection groupée (portée au filtre courant) → restauration
const rowCount = await page.locator('.trash-item').count();
expect(rowCount).toBeGreaterThanOrEqual(2);
await page.locator('label input[type=checkbox]').check();
await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1);
await page.locator('button.btn-sm', { hasText: 'Restore' }).click();
await page.waitForTimeout(900);
await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0);
await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0);
let ids = await trashIds(page);
expect(ids).not.toContain(idA);
expect(ids).not.toContain(idB);
// Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux)
const idC = await mkTrashed(page, nameC);
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(900);
const rowC = page.locator('.trash-item', { hasText: nameC });
await expect(rowC).toHaveCount(1);
await rowC.first().locator('button[title="Restore"]').click();
await page.waitForTimeout(900);
await expect(rowC).toHaveCount(0);
ids = await trashIds(page);
expect(ids).not.toContain(idC);
// Purge totale — seulement si la poubelle ne contient QUE des pages E2E
// (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui).
await mkTrashed(page, nameD);
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(900);
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
const all = (await api(page, 'GET', '/board/api/trash')).body || [];
if (all.every((i) => i.name.startsWith('E2E Trash'))) {
await page.getByRole('button', { name: 'Empty Trash' }).click();
await page.waitForTimeout(900);
expect(await trashIds(page)).toHaveLength(0);
await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible();
} else {
console.log('SKIP purge : la poubelle contient des pages non-E2E ' +
JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name)));
}
const fatal = errs.filter((e) => e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error'));
expect(fatal, fatal.join('\n')).toHaveLength(0);
});
+5 -1
View File
@@ -1865,7 +1865,11 @@ applyAIBlocks(text){
})
.catch(function(){ self.showToast('Move failed'); });
},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();;fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
// Le route était /board/api/pages/{id}/trash → 404 permanent (le .then
// naviguait quand même : la page n'allait JAMAIS à la poubelle depuis
// l'éditeur). Route réelle : /api/pages/{id}/trash, et on ne navigue
// plus si la réponse n'est pas ok.
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();fetch(`/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);window.location.href='/';}).catch(()=>{window.showToast('Move to Trash failed','error');});},
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
+4 -1
View File
@@ -453,7 +453,10 @@ def test_dashboard_page_content_rename_trash(client):
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["title"] == "Renommée A32"
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
# v7.45.5 : parent_section n'est PLUS réécrit en 'Trash' (deleted_at est
# la source de vérité) — l'ancienne assertion codait le bug : la page
# restaurée restait 'Trash' et disparaissait de Library/Recents/Private.
assert row["parent_section"] != "Trash" and row["deleted_at"] is not None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
+123
View File
@@ -0,0 +1,123 @@
"""Trash — v7.45.5.
Trois régressions couvertes :
1. les routes trash n'avaient AUCUNE vérification de session (le CSRF seul ne
protège pas : cookie lisible + en-tête forgé) ;
2. la suppression depuis l'éditeur réécrivait ``parent_section='Trash'`` et la
restauration ne le remettait pas → page invisible en Library/Recents/Private ;
3. ``POST /board/api/trash/empty`` (purge totale) n'existait pas.
"""
from tests.conftest import anon_csrf, login_test_client
def _mk_page(title="To trash", workspace="bruno/flowdeck", section="Private", parent_id=None):
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, parent_id) "
"VALUES (?, ?, '', 'blocks', ?, ?)",
(workspace, title, section, parent_id),
)
conn.commit()
return cur.lastrowid
def _row(page_id):
from app.db import get_conn
with get_conn() as conn:
return conn.execute(
"SELECT parent_section, deleted_at FROM pages WHERE id=?", (page_id,)
).fetchone()
def test_trash_requires_session(client):
pid = _mk_page()
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403
assert client.get("/board/api/trash").status_code == 401
assert client.post(f"/api/pages/{pid}/trash").status_code == 401
assert client.post(f"/board/api/trash/{pid}/restore").status_code == 401
assert client.delete(f"/board/api/trash/{pid}").status_code == 401
assert client.post("/board/api/trash/empty").status_code == 401
# rien n'a été touché
row = _row(pid)
assert row["deleted_at"] is None
def test_soft_delete_then_restore_keeps_section(client):
login_test_client(client)
pid = _mk_page(title="Restore me")
r = client.post(f"/api/pages/{pid}/trash")
assert r.status_code == 200
row = _row(pid)
assert row["deleted_at"], "la page doit être marquée supprimée"
assert row["parent_section"] != "Trash", "parent_section ne doit plus être réécrit"
items = client.get("/board/api/trash").json()
hit = next((i for i in items if i["id"] == pid), None)
assert hit, "la page doit apparaître dans la poubelle"
assert hit["deleted_at"] and hit["path"] and hit["icon"]
r = client.post(f"/board/api/trash/{pid}/restore")
assert r.status_code == 200
row = _row(pid)
assert row["deleted_at"] is None
assert row["parent_section"] != "Trash"
assert not any(i["id"] == pid for i in client.get("/board/api/trash").json())
def test_permanent_delete(client):
login_test_client(client)
pid = _mk_page(title="Gone for good")
client.post(f"/api/pages/{pid}/trash")
assert client.delete(f"/board/api/trash/{pid}").status_code == 200
from app.db import get_conn
with get_conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (pid,)).fetchone()[0] == 0
def test_empty_trash_purges_only_trashed(client):
login_test_client(client)
ids = [_mk_page(title=f"purge-{i}") for i in range(3)]
keep = _mk_page(title="keep-me")
for pid in ids:
client.post(f"/api/pages/{pid}/trash")
r = client.post("/board/api/trash/empty")
assert r.status_code == 200
assert r.json()["deleted"] == 3
from app.db import get_conn
with get_conn() as conn:
placeholders = ",".join("?" * len(ids))
assert conn.execute(
f"SELECT COUNT(*) FROM pages WHERE id IN ({placeholders})", ids
).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (keep,)).fetchone()[0] == 1
def test_boot_repairs_restored_rows_marked_trash(client):
"""Le correctif de db.init_db() remet en 'Private' les pages restaurées que
l'ancien soft-delete laissait marquées 'Trash' (donc invisibles partout)."""
login_test_client(client)
from app.db import get_conn, init_db
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
"VALUES ('', 'ghost', '', 'blocks', 'Trash')"
)
conn.commit()
pid = cur.lastrowid
init_db()
with get_conn() as conn:
row = conn.execute("SELECT parent_section FROM pages WHERE id=?", (pid,)).fetchone()
assert row["parent_section"] == "Private"