diff --git a/CHANGELOG.md b/CHANGELOG.md index 102e485..21fce45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog - FlowDeck +## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers) + +### Changed + +- **A28 lot 2** : `app/routers/dashboard.py` (**2 735 lignes, 63 routes**) + devient le package `app/routers/dashboard/` : + · `local_workspace` 559 L (15 routes), `pages_html` 485 (6), + `account_settings` 439 (16), `workspace` 321 (9), `pages_api` 240 (6), + `workspaces` 131 (6), `public` 78 (1), `account_api` 77 (4) + · `_common.py` (774 L) : les **15 helpers top-level intercalés** dans + l'ancien fichier + état (`logger`, `_VERSION`, `WORKSPACE_COOKIE`) + · `__init__.py` : re-export complet (les 7 importateurs existants — + main, board ×3, my_tasks, web_clipper, wiki, sites, tests — n'ont + rien changé) + `__all__` +- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique + byte-à-byte** (ordre d'enregistrement des routes préservé) + +### Fixed (pièges de la découpe, rattrapés) + +- segment décorateur sans sa fonction (`def`) → corps perdus en silence : + assert `def in seg` ajoutée + récupération depuis git +- collision `settings` : la section `settings.py` entrait en conflit avec + `from app.config import settings` du header (`hasattr` du fromlist de + Python → la section était ignorée) → renommée `account_settings` +- `WORKSPACE_COOKIE` utilisé dans `workspaces.py` sans import (F821) → + import `._common` ajouté +- script `__all__` tronquant la fin du fichier → `__init__.py` réécrit + +### Notes + +- Reste A28 : `collections.py` 2 622 L, `board.py` 2 101 L +- Suite complète : **1091/1091** · ruff OK + ## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 235ef63..c5e9393 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1148,7 +1148,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.* - [x] **A27 — 13 900 lignes de JS inline — TERMINÉ 2026-10-01 (extraction 11 874 L / -85 % + eslint 0/0) — reste : structurel seul (base 1 523 L)**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint.* — **phase 1 faite 2026-10-01** : **7 templates sans Jinja → 9 fichiers `static/js/*.js` (4 243 lignes, -30 % du JS inline)** : agent_panel_1/2 (4 243… dont le 1 788 L livré sur CHAQUE page), library 1039, gitea_workspace 626, _icon_picker_1/2, _ctx_menu, import, workspaces — **un fichier par bloc** (ordre/timing identique, pas de defer, attributs conservés, `?v={{ asset_version }}`), `node --check` vert sur les 9, 0 script inline restant dans les cibles, suite 1089/1089. **Lint** : eslint **installé globalement** (`npm i -g eslint`, la config était déjà flat v9 sans dépendances) → `eslint static/js` = **0 erreur / 120 warnings** (no-unused-vars 69, no-empty 36, no-undef 15 — baseline à nettoyer opportunistiquement). **Phase 2a faite 2026-10-01 (l'éditeur, le plus gros bloc interpolé)** : `_page_editor_scripts` **2 516 L extraites** vers `static/js/page_editor_scripts.js` — recette « config JSON » : les 8 interpolations Jinja lisent `PD` = `JSON.parse(#page-data)` (le bloc JSON **existait déjà** juste avant le script) ; la route enrichit `page_data` de `updated_at`, `created_at`, `user_id`, `is_shared` (**dérivé hoisté : une seule expression pour le ctx ET le JSON**) et `clip_icon` (macro `fd_icon` rendue côté serveur) ; `workspace_key` reste vide comme avant (jamais défini dans ce ctx — parité). **Cumul A27 : 6 759 L extraites** (13 904 → 7 145 inline). **8 tests adaptés** (ils lisaient le template source → lisent maintenant `static/js/page_editor_scripts.js` ; `pageIsShared:true` → parsing du JSON `#page-data` → `is_shared is True`). **Phase 2b faite 2026-10-01** : **+3 801 L** extraites avec la même recette config JSON : `local_workspace` (2031 — `lw-config` : current_folder_id/workspace_id), `settings` (1093 — `st-config` : avatar/user/is_admin/auth_method, **2 routes rendent ce template**, expressions `or ""` préservées pour Undefined), `_page_editor_realtime` (531 — `rt-config` : SELF id/login/full_name/color), `board` (146 — `bd-config` : owner/repo/initial_view). BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON) au lieu d'être interpolées dans des strings JS. **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**), `node --check` vert ×4, suite 1089/1089, eslint **0 erreur / 279 warnings** (12 fichiers). **Phase 2c faite 2026-10-01 (BILAN : extraction TERMINÉE)** : `database_table` **1 314 L extraites** — le Jinja du bloc était confiné à la construction de l'objet de config (4 clés + `{% if collection_data %}`) → config JSON `null`-vs-objet (`#db-config`), le JS appelle `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)` : les 2 branches Jinja disparaissent ; 2 tests adaptés (source → `database_table.js`) + `FlowDeckDB` dans src. **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 + 1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`, `node --check` vert partout, suite 1089/1089. **Lint A27 TERMINÉ 2026-10-01 : `eslint static/js` = 0 erreur / 0 warning** (285 → 0, 22 fichiers). 3 familles traitées : (1) **no-empty ×70** = tous des `catch (x) {}` vides → `catch { /* volontaire */ }` (binding optionnel ES2019 + commentaire : passe no-empty ET no-unused-vars, zéro changement de comportement) ; (2) **no-unused-vars ×171** = bindings de catch retirés + 24 lignes mortes déterministes (suppressions avec assert sur le texte exact : `var self = this` ×8, compteurs jamais lus `restored++`/`resolved++`/`acc`/`today`, `uid()`/`propName()` sans 1 appel, etc.) + `/* exported */` sur les **10 fonctions appelées depuis les attributs HTML** (vérifiées par grep, 1 template chacune) ; (3) **no-undef ×44** = globaux réels déclarés dans `eslint.config.mjs` (`getSvgIcon` = script inline de `base.html`, `TextDecoder` = API navigateur, `Prism` = CDN) + **2 vrais correctifs** : `settings.js` utilisait `typeof toast === 'function'` (guard toujours faux → les toasts timezone/SAML ne s'affichaient JAMAIS) → `window.showToast`, et `_wsInitData = window._wsInitData` (auto-affectation sans effet, global implicite) supprimé. **Reste A27** : `base` 1 523 L structurel (inline par nature, décision assumée) + ~500 L de petits blocs hors cibles. Effort : **L** (fait). -- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Reste A28** : `dashboard.py` 2 735 L, `collections.py` 2 622 L, `board.py` 2 101 L (même recette) → suite 1091/1091, version 7.29.0. +- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Reste A28** : `collections.py` 2 622 L, `board.py` 2 101 L → suite 1091/1091, version 7.30.0. - [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**. - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* - [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**. @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 — reste : dashboard/collections/board. +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 — reste : collections/board. diff --git a/VERSION b/VERSION index d88eca0..ea5c418 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.29.0 +7.30.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index e188dfd..d4398c9 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.30.0 (audit — A28 lot 2 : dashboard.py 2 735 L → package 10 fichiers) | **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/main.py b/app/main.py index 10972e9..7478594 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.29.0", + version="7.30.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py deleted file mode 100644 index ca7c768..0000000 --- a/app/routers/dashboard.py +++ /dev/null @@ -1,2735 +0,0 @@ -"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data.""" -from __future__ import annotations - -import logging -from datetime import UTC - -from fastapi import APIRouter, Body, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse - -from app.auth.session import SessionManager -from app.config import settings -from app.db import get_conn -from app.services.gitea_client import get_user_gitea_client, gitea - -logger = logging.getLogger(__name__) -router = APIRouter(tags=["dashboard"]) - -_VERSION = None - - -def _get_app_version() -> str: - """Read version from VERSION file with caching.""" - global _VERSION - if _VERSION is not None: - return _VERSION - try: - import os - version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION") - if os.path.exists(version_path): - with open(version_path) as f: - _VERSION = f.read().strip() - else: - # Docker fallback - version_path = "/app/VERSION" - if os.path.exists(version_path): - with open(version_path) as f: - _VERSION = f.read().strip() - else: - _VERSION = "0.0.0" - except Exception: - _VERSION = "0.0.0" - return _VERSION - - -def _get_user_or_redirect(request: Request): - """Return decoded user or a RedirectResponse to login page. - Skips redirect when DB has no users (fresh install / test env).""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user: - # Allow through if no users exist yet (fresh install / tests) - try: - with get_conn() as conn: - count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - if count == 0: - return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} - except Exception: - logger.exception("_get_user_or_redirect") - return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) - return user - - -def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: - """Return list of local workspaces for a user.""" - if not user: - return [] - try: - with get_conn() as conn: - rows = conn.execute( - "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", - (user["id"],) - ).fetchall() - return [{"id": r["id"], "name": r["name"]} for r in rows] - except Exception: - return [] - - -def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict: - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - ws = user.get("login", "Bruno") if user else "Bruno" - initial = ws[0].upper() if ws else "B" - - # Get avatar info from DB - avatar_url = "" - avatar_color = "#3A3A3A" - if user: - try: - with get_conn() as conn: - row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone() - if row: - avatar_url = row["avatar_url"] or "" - avatar_color = row["avatar_color"] or "#3A3A3A" - except Exception: - logger.exception("_sidebar_data") - - recent_pages = [] - for repo in repos[:10]: - full_name = repo.get("full_name", "") - recent_pages.append({ - "id": full_name, - "name": repo.get("name", full_name), - "icon": "folder", - "url": f"/board/{full_name}", - "active": False, - "indent": 0, - "depth": 0, - "has_children": False, - "children": [], - }) - - # Active workspace from cookie (skip on pages like /workspaces where no - # workspace context should be shown) - from app.routers.board import _load_workspace_pages - ws_cookie = request.cookies.get("flowdeck_workspace", "") - active_ws_name = "Workspace" - workspace_pages = [] - gitea_workspace = False - gitea_owner = "" - gitea_repo = "" - has_active_workspace = False - local_ws_id = 0 - - if ws_cookie and ws_cookie.startswith("gitea:"): - # Gitea workspace: set owner/repo for client-side tree loading - # AND open the local workspace mirror of the same name in the - # sidebar's top "My Workspaces" section, in parallel with the - # Gitea repository tree. - parts = ws_cookie.split(":", 2) - if len(parts) >= 3: - gitea_owner = parts[1] - gitea_repo = parts[2] - active_ws_name = f"{gitea_owner}/{gitea_repo}" - gitea_workspace = True - has_active_workspace = True - # Load the local mirror workspace tree so it appears in "My - # Workspaces" alongside the Gitea repository section. - if user: - try: - with get_conn() as conn: - row = conn.execute( - "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", - (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") - ).fetchone() - if row: - local_ws_id = row["id"] - workspace_pages = _load_workspace_pages(str(local_ws_id)) - except (ValueError, Exception): - pass - elif include_workspace and ws_cookie and user: - try: - wsi = int(ws_cookie) - with get_conn() as conn: - # Verify this workspace belongs to the current user - row = conn.execute( - "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", - (wsi, user["id"]) - ).fetchone() - if row: - active_ws_name = row["name"] - workspace_pages = _load_workspace_pages(ws_cookie) - has_active_workspace = True - # else: stale cookie from another user — ignore - except (ValueError, Exception): - pass - - # Auth method & OAuth badge data - auth_method = "local" - gitea_linked = False - github_linked = False - if user and user.get("id"): - try: - with get_conn() as conn: - am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() - if am_row and am_row["auth_method"]: - auth_method = am_row["auth_method"] - tokens = conn.execute( - "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) - ).fetchall() - for t in tokens: - if t["provider"] == "gitea": - gitea_linked = True - elif t["provider"] == "github": - github_linked = True - except Exception: - logger.exception("_sidebar_data") - - # Get local workspace ID for Gitea workspace mirror - local_ws_id = 0 - if gitea_workspace and gitea_owner and gitea_repo: - try: - with get_conn() as conn: - row = conn.execute( - "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", - (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") - ).fetchone() - if row: - local_ws_id = row["id"] - except Exception: - logger.exception("_sidebar_data") - - # Private pages for mirror workspace (when Gitea remote active) - private_pages = [] - if gitea_workspace and local_ws_id: - try: - with get_conn() as conn: - pp_rows = conn.execute( - "SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20", - (local_ws_id,) - ).fetchall() - private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] - except Exception: - logger.exception("_sidebar_data") - - # Shared / received / published pages for the sidebar (Par moi / Avec moi) - shared_made_pages = [] - shared_received_pages = [] - published_pages = [] - shared_pages = [] - if user and user.get("id"): - from app.routers.board import _load_shared_sidebar_pages - shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"]) - - sidebar = { - "workspace_name": ws, "workspace_initial": initial, - "active_ws_name": active_ws_name, - "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", - "gitea_workspace": gitea_workspace, - "gitea_owner": gitea_owner, - "gitea_repo": gitea_repo, - "local_ws_id": local_ws_id, - "workspace_pages": workspace_pages, - "current_page": "Dashboard", "last_edited": "now", - "recent_pages": recent_pages, - "private_pages": private_pages, - "favorite_pages": [], - "shared_pages": shared_pages, - "shared_made_pages": shared_made_pages, - "shared_received_pages": shared_received_pages, - "published_pages": published_pages, - "user": user, - "avatar_url": avatar_url, - "avatar_color": avatar_color, - "auth_method": auth_method, - "gitea_linked": gitea_linked, - "github_linked": github_linked, - "has_active_workspace": has_active_workspace, - "app_version": _get_app_version(), - } - - sidebar["local_workspaces"] = _local_workspaces_for_user(user) - - return sidebar - - -@router.get("/trash", response_class=HTMLResponse) -def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): - """Trash page — scoped to workspace if owner/repo provided.""" - from app.routers.board import _sidebar_data as board_sidebar - from app.templating import ENV - env = ENV - 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' - if ws_key: - rows = conn.execute( - "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' 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", - ).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 - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - sidebar["nav_workspace_id"] = ws["id"] if ws else 0 - template = env.get_template("trash.html") - return template.render(**sidebar) - - -@router.get("/library", response_class=HTMLResponse) -def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): - """Library page — tabbed view (recents, favorites, shared, published, private, workspace). - - Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. - """ - from app.routers.board import _sidebar_data as board_sidebar - from app.templating import ENV - env = ENV - sidebar = board_sidebar(request, owner, repo) - # Pass active workspace for breadcrumb nav menu - ws_key_ws = f"{owner}/{repo}" if owner and repo else "" - if ws_key_ws: - with get_conn() as conn: - ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone() - sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0 - else: - ws = _get_active_workspace(request, user_id=_get_user_id(request)) - sidebar["nav_workspace_id"] = ws["id"] if ws else 0 - - template = env.get_template("library.html") - sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0) - # Gitea workspace context for Repository tab - sidebar["is_gitea_workspace"] = bool(owner and repo) - sidebar["gitea_workspace_owner"] = owner - sidebar["gitea_workspace_repo"] = repo - return template.render(**sidebar) - - -@router.get("/pages/{page_id}", response_class=HTMLResponse) -def view_page_root(request: Request, page_id: int): - """Render a Markdown page at root level with workspace context — or file viewer. - ?embed=1 — minimal mode for side peek (editor only, no header).""" - embed = request.query_params.get("embed") == "1" - from app.routers.board import _sidebar_data as board_sidebar - from app.templating import ENV - env = ENV - with get_conn() as conn: - row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - return RedirectResponse("/workspaces", status_code=302) - page = dict(row) - # v6.5.0: synced blocks resolve server-side at read time. - from app.services.synced_blocks import resolve_content_json - page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) - - ws = page.get("workspace", "") - parts = ws.split("/") if "/" in ws else ["", ""] - owner, repo = parts[0], parts[1] if len(parts) > 1 else "" - sidebar = board_sidebar(request, owner, repo) - # Load sub-pages - with get_conn() as conn: - subs = conn.execute( - "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", - (page_id,), - ).fetchall() - fav = conn.execute( - "SELECT id FROM favorites WHERE user_id=? AND page_id=?", - (1, page_id), - ).fetchone() - - # Build page_data, including file metadata for uploaded files - _locked = bool(page.get("is_locked", 0)) - _locked_by = page.get("locked_by") if "locked_by" in page else None - _sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - _uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None - _can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid) - page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), - "is_locked": _locked, - "locked_by": _locked_by, - "can_edit": _can_edit, - "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, - "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} - - # For file pages, extract file metadata and add to page_data - if page.get("content_format") == "file": - import json as _json - try: - meta = _json.loads(page.get("content", "{}")) - except _json.JSONDecodeError: - meta = {} - file_path = meta.get("file_path", "").replace("\\", "/") - mime_type = meta.get("mime_type", "application/octet-stream") - file_size = meta.get("size", 0) - fp_parts = file_path.split("/") - ws_id = "" - for p in fp_parts: - if p.startswith("workspace_"): - ws_id = p.replace("workspace_", "") - break - filename = fp_parts[-1] if fp_parts else page.get("title", "File") - from urllib.parse import quote - safe_name = quote(filename, safe='') - file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else "" - page_data["file_url"] = file_url - page_data["file_mime"] = mime_type - page_data["file_size"] = file_size - page_data["file_name"] = filename - - # For collection (database) pages, load collection + properties + pages - collection_data = None - if page.get("content_format") == "collection" and page.get("collection_id"): - with get_conn() as conn: - col = conn.execute( - "SELECT * FROM collections WHERE id=?", (page["collection_id"],) - ).fetchone() - if col: - props = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", - (page["collection_id"],), - ).fetchall() - ] - cpages = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", - (page["collection_id"],), - ).fetchall() - ] - cviews = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", - (page["collection_id"],), - ).fetchall() - ] - collection_data = { - "collection": dict(col), - "properties": props, - "pages": cpages, - "views": cviews, - } - page_data["collection_id"] = page["collection_id"] - - with get_conn() as conn: - nav_crumbs = _nav_breadcrumb(conn, page_id) - # dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27) - page_is_shared = ( - bool(page.get("is_shared", 0)) - or page.get("share_mode", "private") != "private" - or bool(page.get("published", 0)) - ) - ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], - "page_favorited": fav is not None, - "page_share_mode": page.get("share_mode", "private"), - "page_published": bool(page.get("published", 0)), - "page_is_shared": page_is_shared, - "page_data": page_data, - "collection_data": collection_data, - "breadcrumb_items": nav_crumbs, - "nav_workspace_id": page.get("workspace_id") or 0, - "nav_page_id": page_id, - "embed_mode": embed} - # A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON) - # au lieu des interpolations Jinja — une seule source, même calculs que le - # ctx ci-dessus. - from app.templating import ENV as _ENV27 - page_data.update( - updated_at=page.get("updated_at", ""), - created_at=page.get("created_at", ""), - user_id=_uid or 0, - is_shared=page_is_shared, - clip_icon=_ENV27.from_string( - "{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}" - ).render(), - ) - # Select template: collection pages use database table view - if page.get("content_format") == "collection" and not embed: - template = env.get_template("page_editor_collection.html") - else: - template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") - response = template.render(**ctx) - return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) - - -@router.get("/accounts", response_class=HTMLResponse) -def accounts_page(request: Request): - """Account management panel.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - with get_conn() as conn: - users = conn.execute( - "SELECT id, login, full_name, email, avatar_url, avatar_color, " - "is_admin, is_active, created_at FROM users ORDER BY created_at DESC" - ).fetchall() - ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} - template = env.get_template("accounts.html") - return template.render(**ctx) - - -@router.get("/help", response_class=HTMLResponse) -def help_page(request: Request): - """Comprehensive help & documentation page.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, []) - # Render via a block-based template so content_html lands in {% block content %} - block_tpl = env.from_string( - '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' - ) - return HTMLResponse(block_tpl.render( - **sidebar, - request=request, - page_title="Help", - title_prefix="Help", - page_icon="❓", - content_html=""" -
Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.
-FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.
-Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.
-Organize your work into separate workspaces. Each has its own pages and files.
-Connect your Gitea account to access repositories directly from FlowDeck.
-Share pages with collaborators or publish them to the web.
-/p/your-slugFind all your content in one place with powerful filtering.
-Install FlowDeck as an app and keep working without a connection.
-
-FlowDeck supports three authentication methods:
-Local Email + password — create an account on the login page.
-Gitea OAuth Login with your Gitea account. Your repos appear as workspaces.
-GitHub OAuth Login or link your GitHub account in Settings → Integrations.
-Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
-
-SSO Enterprise SSO (v6.7.0) — sign in with your organization account.
-For administrators: open Settings → Admin → SSO / Enterprise and pick a provider:
-• SAML 2.0 — paste the IdP Entity ID, SSO URL and signing certificate, then give the IdP this
-/auth/saml/metadata link (it contains the SP Entity ID, ACS URL and certificate).
-• OpenID Connect — paste the Issuer URL, Client ID and Client Secret (PKCE is used, scopes default to openid profile email).
-• Provisioning — accounts are created automatically on first login, groups from the IdP map to workspace roles,
-and SSO only disables local login (admins keep their local door). Every attempt is audited in
-Settings → Admin → SSO / Enterprise (login history).
-
-FlowDeck is a Progressive Web App: pages you visited stay available offline and your
-edits are saved locally, then synchronised when the connection returns.
-Install: open your browser menu and choose Install app (Chrome/Edge) or
-Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
-Offline editing: while offline, the editor stores changes in the browser
-(IndexedDB) and shows an offline banner with the number of pending changes. A
-⟳ icon appears next to pages that have unsynced edits.
-Reconnection: the queue is replayed automatically (and via Background Sync).
-A spinner badge appears while syncing, followed by a confirmation toast.
-Conflicts: if a page changed on the server, the latest edit wins and a notice is
-shown. If a page was deleted server-side, your offline copy is recreated as an orphan
-page. If a page with the same title already exists, the offline copy is renamed
-“Title (copie offline)”.
-
-FlowDeck exposes a full REST API under /api/v2 for third-party integrations.
-Auth: create a token in Settings → API tokens, then send it as
-Authorization: Bearer <token>. Tokens carry scopes
-read, write or admin (a higher scope implies the lower ones).
-Features: CRUD on collections, pages, properties, views, comments, notifications,
-favorites, tags, sharing, sprints and templates; pagination (?limit=&offset= +
-X-Total-Count), filters (filter[prop]=value), sorting, full-text search
-(/api/v2/search), idempotency (Idempotency-Key) and RFC 7807 error bodies.
-Reference: interactive OpenAPI docs at /docs
-(also /redoc, docs/openapi-v2.json).
-
-• Toggle the sidebar with the « button in the top-left corner.
-• Switch between workspaces using the dropdown menu in the sidebar header.
-• The Private section appears when a remote workspace is active — files here stay local.
-• Hover over any sidebar item to see action buttons (favorite, share, delete).
-• Use Ctrl+Click or Shift+Click to multi-select items in the sidebar.
-
{page.get('content', '')}
" - elif page.get("content"): - # Plain text / markdown - text = page["content"] - content_html = f"{text}"
-
- template = env.get_template("public_page.html")
- return template.render(
- title=page["title"] or "Untitled",
- content_html=content_html,
- updated_at=page.get("updated_at", ""),
- created_at=page.get("created_at", ""),
- cover_url=page.get("cover_url", ""),
- page_icon=page.get("page_icon", ""),
- )
-
-
-def _sanitize_id(block_id: str) -> str:
- """Sanitize a block id for use as an HTML anchor (only alnum kept)."""
- if not block_id:
- return ""
- return "".join(ch for ch in str(block_id) if ch.isalnum())
-
-
-def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
- """Render FlowDeck blocks as plain HTML for public pages.
-
- v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
- ``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
- """
- html_parts = []
-
- def _wiki(c: str) -> str:
- if titles and ("[[fdpage:" in c or "[[fddate:" in c):
- from app.services.wiki_links import resolve_tokens_html
- return resolve_tokens_html(c, titles)
- return c
-
- for b in blocks:
- t = b.get("type", "paragraph")
- c = _wiki(b.get("content", "") or "")
- if t == "heading_1":
- html_parts.append(f'{c}' - ) - elif t == "table_of_contents": - toc = [ - x for x in blocks - if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip() - ] - if toc: - items = [] - for h in toc: - lvl = int(h["type"].split("_")[-1]) - items.append( - f'
'
- f'{lang_label}'
- f'{c}'
- )
- elif t == "divider":
- html_parts.append('{c}
') - return "\n".join(html_parts) - - -# ═══════════ Library page actions API ═══════════ - -@router.get("/api/pages/{page_id:int}/content") -def api_page_content(page_id: int): - """Get page content for side peek preview.""" - with get_conn() as conn: - row = conn.execute( - "SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL", - (page_id,), - ).fetchone() - if not row: - return JSONResponse({"error": "Not found"}, status_code=404) - # v6.5.0: resolve synced blocks server-side (fresh content on read). - from app.services.synced_blocks import resolve_content_json - return { - "title": row["title"], - "content": resolve_content_json(row["content"], row["content_format"]), - "format": row["content_format"] or "blocks", - } - - -@router.put("/api/pages/{page_id:int}/rename") -def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})): - """Inline rename a page title.""" - title = (body.get("title") or "").strip() - if not title: - return JSONResponse({"error": "Title required"}, status_code=400) - with get_conn() as conn: - conn.execute( - "UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", - (title, page_id), - ) - # v6.5.0: renaming a database row's content page updates the row. - from app.services.row_pages import sync_page_title_to_row - sync_page_title_to_row(conn, page_id) - conn.commit() - return {"status": "ok", "title": title} - - -@router.post("/api/pages/{page_id:int}/trash") -def api_trash_page(page_id: int): - """Soft-delete a page (move to trash).""" - with get_conn() as conn: - conn.execute( - "UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?", - (page_id,), - ) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/pages/{page_id:int}/convert-to-database") -def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})): - """Convert a page into a full-page database (Notion-style). - - Creates a collection linked to this page, adds the default 'Name' property, - and sets the page's content_format to 'collection'. - """ - import json as _json - db_name = (body.get("name") or "").strip() - - with get_conn() as conn: - page = conn.execute( - "SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", - (page_id,), - ).fetchone() - if not page: - return JSONResponse({"error": "Page not found"}, status_code=404) - - if not db_name: - db_name = page["title"] or "New Database" - - # Create the collection - cur = conn.execute( - """INSERT INTO collections - (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) - VALUES (?, '', '📋', '[]', 0, ?, ?)""", - (db_name, page_id, page["workspace_id"]), - ) - collection_id = cur.lastrowid - - # Create default "Name" property (text, position 0) - conn.execute( - """INSERT INTO collection_properties - (collection_id, name, prop_type, position, required, visible_in_views) - VALUES (?, 'Name', 'title', 0, 1, 1)""", - (collection_id,), - ) - - # Create default "Table" view - conn.execute( - """INSERT INTO collection_views - (collection_id, name, view_type, config_json, position) - VALUES (?, 'Table', 'table', ?, 0)""", - (collection_id, _json.dumps({"visible_properties": ["Name"]})), - ) - - # Update the page to be a database page - conn.execute( - "UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (collection_id, page_id), - ) - - conn.commit() - - return { - "status": "converted", - "collection_id": collection_id, - "name": db_name, - "view_url": f"/pages/{page_id}", - } - - -@router.get("/api/collections/{collection_id:int}/table-data") -def api_collection_table_data(collection_id: int): - """Get collection properties + pages for rendering the table view.""" - with get_conn() as conn: - coll = conn.execute( - "SELECT * FROM collections WHERE id=?", (collection_id,) - ).fetchone() - if not coll: - return JSONResponse({"error": "Collection not found"}, status_code=404) - - properties = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - - pages = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - - views = [ - dict(r) for r in conn.execute( - "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - - return { - "collection": dict(coll), - "properties": properties, - "pages": pages, - "views": views, - } - - -@router.post("/api/collections/{collection_id:int}/pages") -def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})): - """Create a new page (row) in a collection.""" - import json as _json - title = body.get("title", "New page").strip() or "New page" - icon = body.get("icon", "file") - - with get_conn() as conn: - coll = conn.execute( - "SELECT id FROM collections WHERE id=?", (collection_id,) - ).fetchone() - if not coll: - return JSONResponse({"error": "Collection not found"}, status_code=404) - - # Get next position - max_pos = conn.execute( - "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", - (collection_id,), - ).fetchone()[0] - - # Load default property values from collection properties - props = [ - dict(r) for r in conn.execute( - "SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position", - (collection_id,), - ).fetchall() - ] - default_values = {} - for p in props: - if p["prop_type"] == "title": - default_values[str(p["id"])] = title - # Caller-provided values (e.g. board "add card in column X") win. - incoming = body.get("properties") or {} - if isinstance(incoming, dict): - default_values.update(incoming) - - from app.services.property_types import apply_auto_properties - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1} - apply_auto_properties(props, default_values, user, is_create=True) - - cur = conn.execute( - """INSERT INTO collection_pages - (collection_id, title, icon, cover_url, position, property_values_json) - VALUES (?, ?, ?, ?, ?, ?)""", - (collection_id, title, icon, body.get("cover_url", ""), max_pos, - _json.dumps(default_values)), - ) - page_id = cur.lastrowid - conn.commit() - - return { - "id": page_id, - "title": title, - "icon": icon, - "position": max_pos, - "property_values_json": default_values, - "status": "created", - } diff --git a/app/routers/dashboard/__init__.py b/app/routers/dashboard/__init__.py new file mode 100644 index 0000000..795d06a --- /dev/null +++ b/app/routers/dashboard/__init__.py @@ -0,0 +1,77 @@ +"""FlowDeck — Dashboard (pages HTML + API de l'app). + +Découpe A28 : l'ancien `dashboard.py` (2 735 lignes, 63 routes) est +devenu ce package — un module par concern, helpers dans `_common`, +re-export de tout ce que les 7 importateurs existants utilisent +(main, board, my_tasks, web_clipper, wiki, sites, tests). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter + +from . import ( # ordre = ordre d'enregistrement d'origine (openapi identique) + account_api, + account_settings, + local_workspace, + pages_api, + pages_html, + public, + workspace, + workspaces, +) +from ._common import ( # noqa: F401 — re-export des helpers + _VERSION, + WORKSPACE_COOKIE, + _build_breadcrumb, + _build_tree_children, + _file_page_disk_path, + _format_size, + _get_active_workspace, + _get_app_version, + _get_user_id, + _get_user_or_redirect, + _local_workspaces_for_user, + _nav_breadcrumb, + _render_blocks_public, + _require_page_view, + _require_user_id, + _sanitize_id, + _sidebar_data, +) + +logger = logging.getLogger(__name__) +router = APIRouter() +for _mod in ( + pages_html, + account_api, + workspace, + local_workspace, + workspaces, + account_settings, + public, + pages_api, +): + router.include_router(_mod.router) + +__all__ = [ + "router", + "WORKSPACE_COOKIE", + "_VERSION", + "_build_breadcrumb", + "_build_tree_children", + "_file_page_disk_path", + "_format_size", + "_get_active_workspace", + "_get_app_version", + "_get_user_id", + "_get_user_or_redirect", + "_local_workspaces_for_user", + "_nav_breadcrumb", + "_render_blocks_public", + "_require_page_view", + "_require_user_id", + "_sanitize_id", + "_sidebar_data", +] diff --git a/app/routers/dashboard/_common.py b/app/routers/dashboard/_common.py new file mode 100644 index 0000000..5896092 --- /dev/null +++ b/app/routers/dashboard/_common.py @@ -0,0 +1,774 @@ +"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28). + +Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état : +_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package. +""" +from __future__ import annotations + +import logging + +from fastapi import HTTPException, Request +from fastapi.responses import RedirectResponse + +from app.auth.session import SessionManager +from app.config import settings +from app.db import get_conn + +logger = logging.getLogger(__name__) + +_VERSION = None + +WORKSPACE_COOKIE = "flowdeck_workspace" + + + +def _get_app_version() -> str: + """Read version from VERSION file with caching.""" + global _VERSION + if _VERSION is not None: + return _VERSION + try: + import os + version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION") + if os.path.exists(version_path): + with open(version_path) as f: + _VERSION = f.read().strip() + else: + # Docker fallback + version_path = "/app/VERSION" + if os.path.exists(version_path): + with open(version_path) as f: + _VERSION = f.read().strip() + else: + _VERSION = "0.0.0" + except Exception: + _VERSION = "0.0.0" + return _VERSION + + + + + + +def _get_user_or_redirect(request: Request): + """Return decoded user or a RedirectResponse to login page. + Skips redirect when DB has no users (fresh install / test env).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + # Allow through if no users exist yet (fresh install / tests) + try: + with get_conn() as conn: + count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if count == 0: + return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} + except Exception: + logger.exception("_get_user_or_redirect") + return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) + return user + + + + + + +def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: + """Return list of local workspaces for a user.""" + if not user: + return [] + try: + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", + (user["id"],) + ).fetchall() + return [{"id": r["id"], "name": r["name"]} for r in rows] + except Exception: + return [] + + + + + + +def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + ws = user.get("login", "Bruno") if user else "Bruno" + initial = ws[0].upper() if ws else "B" + + # Get avatar info from DB + avatar_url = "" + avatar_color = "#3A3A3A" + if user: + try: + with get_conn() as conn: + row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone() + if row: + avatar_url = row["avatar_url"] or "" + avatar_color = row["avatar_color"] or "#3A3A3A" + except Exception: + logger.exception("_sidebar_data") + + recent_pages = [] + for repo in repos[:10]: + full_name = repo.get("full_name", "") + recent_pages.append({ + "id": full_name, + "name": repo.get("name", full_name), + "icon": "folder", + "url": f"/board/{full_name}", + "active": False, + "indent": 0, + "depth": 0, + "has_children": False, + "children": [], + }) + + # Active workspace from cookie (skip on pages like /workspaces where no + # workspace context should be shown) + from app.routers.board import _load_workspace_pages + ws_cookie = request.cookies.get("flowdeck_workspace", "") + active_ws_name = "Workspace" + workspace_pages = [] + gitea_workspace = False + gitea_owner = "" + gitea_repo = "" + has_active_workspace = False + local_ws_id = 0 + + if ws_cookie and ws_cookie.startswith("gitea:"): + # Gitea workspace: set owner/repo for client-side tree loading + # AND open the local workspace mirror of the same name in the + # sidebar's top "My Workspaces" section, in parallel with the + # Gitea repository tree. + parts = ws_cookie.split(":", 2) + if len(parts) >= 3: + gitea_owner = parts[1] + gitea_repo = parts[2] + active_ws_name = f"{gitea_owner}/{gitea_repo}" + gitea_workspace = True + has_active_workspace = True + # Load the local mirror workspace tree so it appears in "My + # Workspaces" alongside the Gitea repository section. + if user: + try: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") + ).fetchone() + if row: + local_ws_id = row["id"] + workspace_pages = _load_workspace_pages(str(local_ws_id)) + except (ValueError, Exception): + pass + elif include_workspace and ws_cookie and user: + try: + wsi = int(ws_cookie) + with get_conn() as conn: + # Verify this workspace belongs to the current user + row = conn.execute( + "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", + (wsi, user["id"]) + ).fetchone() + if row: + active_ws_name = row["name"] + workspace_pages = _load_workspace_pages(ws_cookie) + has_active_workspace = True + # else: stale cookie from another user — ignore + except (ValueError, Exception): + pass + + # Auth method & OAuth badge data + auth_method = "local" + gitea_linked = False + github_linked = False + if user and user.get("id"): + try: + with get_conn() as conn: + am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() + if am_row and am_row["auth_method"]: + auth_method = am_row["auth_method"] + tokens = conn.execute( + "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) + ).fetchall() + for t in tokens: + if t["provider"] == "gitea": + gitea_linked = True + elif t["provider"] == "github": + github_linked = True + except Exception: + logger.exception("_sidebar_data") + + # Get local workspace ID for Gitea workspace mirror + local_ws_id = 0 + if gitea_workspace and gitea_owner and gitea_repo: + try: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") + ).fetchone() + if row: + local_ws_id = row["id"] + except Exception: + logger.exception("_sidebar_data") + + # Private pages for mirror workspace (when Gitea remote active) + private_pages = [] + if gitea_workspace and local_ws_id: + try: + with get_conn() as conn: + pp_rows = conn.execute( + "SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20", + (local_ws_id,) + ).fetchall() + private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] + except Exception: + logger.exception("_sidebar_data") + + # Shared / received / published pages for the sidebar (Par moi / Avec moi) + shared_made_pages = [] + shared_received_pages = [] + published_pages = [] + shared_pages = [] + if user and user.get("id"): + from app.routers.board import _load_shared_sidebar_pages + shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"]) + + sidebar = { + "workspace_name": ws, "workspace_initial": initial, + "active_ws_name": active_ws_name, + "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", + "gitea_workspace": gitea_workspace, + "gitea_owner": gitea_owner, + "gitea_repo": gitea_repo, + "local_ws_id": local_ws_id, + "workspace_pages": workspace_pages, + "current_page": "Dashboard", "last_edited": "now", + "recent_pages": recent_pages, + "private_pages": private_pages, + "favorite_pages": [], + "shared_pages": shared_pages, + "shared_made_pages": shared_made_pages, + "shared_received_pages": shared_received_pages, + "published_pages": published_pages, + "user": user, + "avatar_url": avatar_url, + "avatar_color": avatar_color, + "auth_method": auth_method, + "gitea_linked": gitea_linked, + "github_linked": github_linked, + "has_active_workspace": has_active_workspace, + "app_version": _get_app_version(), + } + + sidebar["local_workspaces"] = _local_workspaces_for_user(user) + + return sidebar + + + + + + +# ═══════════ User API endpoints ═══════════ + +def _get_user_id(request: Request) -> int: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + return user["id"] if user and user.get("id") else 1 + + + + + + +def _require_user_id(request: Request) -> int: + """A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent + plus le fallback « legacy single-user » → id 1 = l'admin seedé).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + return user["id"] + + + + + + +def _file_page_disk_path(page: dict): + """Resolve the on-disk file behind a ``content_format == 'file'`` page. + + Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None + when the row is not a file page, references a non-textual/missing file, or + the path escapes the data root (path-traversal guard). + """ + if (page.get("content_format") or "") != "file": + return None + import json as _json + try: + meta = _json.loads(page.get("content", "{}")) + except (_json.JSONDecodeError, TypeError): + meta = {} + if not isinstance(meta, dict): + return None + rel = (meta.get("file_path") or "").replace("\\", "/").strip() + if not rel or not rel.startswith("uploads/"): + return None + parts = rel.split("/") + if ".." in parts or "." in parts: + return None + from pathlib import Path + root = Path(settings.data_dir).resolve() + full = (root / rel).resolve() + try: + full.relative_to(root) + except ValueError: + return None + if not full.exists() or not full.is_file(): + return None + filename = parts[-1] or page.get("title", "file") + mime = meta.get("mime_type") or "application/octet-stream" + size = meta.get("size") or 0 + return (full, filename, mime, size) + + + + + + +def _require_page_view(request: Request, page_id: int) -> None: + """A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + from app.services.permission_manager import PermissionManager + + if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): + raise HTTPException(404, "Page not found") + + + + + + +def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list: + """Recursively build the tree of children for a node.""" + if parent_id is None: + rows = conn.execute( + "SELECT id, title, parent_section, content_format, content, page_icon, " + "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " + "created_at, updated_at FROM pages " + "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL " + "ORDER BY created_at DESC", + (ws_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, title, parent_section, content_format, content, page_icon, " + "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " + "created_at, updated_at FROM pages " + "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " + "ORDER BY created_at DESC", + (parent_id, ws_id), + ).fetchall() + + # Get workspace owner name for author display + ws_owner = conn.execute( + "SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?", + (ws_id,), + ).fetchone() + author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—" + + # Collect all page IDs to fetch tags in one query + all_ids = [r["id"] for r in rows] + tags_map = {} + favorited_ids = set() + if all_ids: + placeholders = ",".join("?" for _ in all_ids) + tag_rows = conn.execute( + f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt " + f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})", + all_ids, + ).fetchall() + for tr in tag_rows: + tags_map.setdefault(tr["page_id"], []).append({ + "id": tr["id"], "name": tr["name"], "color": tr["color"], + }) + if uid is not None: + fav_rows = conn.execute( + f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})", + [uid, *all_ids], + ).fetchall() + favorited_ids = {fr["page_id"] for fr in fav_rows} + + tree = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + children = _build_tree_children(conn, r["id"], ws_id, uid) + + # Compute size + size = 0 + if r["content_format"] == "file": + import json as _json + try: + meta = _json.loads(r["content"]) + size = meta.get("size", 0) + except Exception: + size = len(r["content"] or "") + else: + size = len(r["content"] or "") + + tree.append({ + "id": r["id"], + "name": r["title"] or "Untitled", + "type": "folder" if is_folder else "page", + "is_folder": is_folder, + "content_format": r["content_format"] if not is_folder else None, + "page_icon": r["page_icon"] or "", + "children": children, + "has_children": len(children) > 0, + "child_count": len(children), + "size": size, + "size_display": _format_size(size), + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "author": author, + "tags": tags_map.get(r["id"], []), + "is_shared": bool(r["is_shared"]), + "favorited": r["id"] in favorited_ids, + }) + return tree + + + + + + +def _format_size(size_bytes: int) -> str: + """Human-readable file size.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + + + + + +def _build_breadcrumb(conn, folder_id: int) -> list: + """Build breadcrumb trail from root to folder_id.""" + breadcrumb = [] + current = folder_id + seen = set() + while current and current not in seen: + seen.add(current) + row = conn.execute( + "SELECT id, title, parent_id, parent_section FROM pages WHERE id=?", + (current,), + ).fetchone() + if row: + breadcrumb.insert(0, { + "id": row["id"], + "name": row["title"] or "Untitled", + "is_folder": row["parent_section"] == "Workspace", + }) + current = row["parent_id"] + else: + break + return breadcrumb + + + + + + +def _nav_breadcrumb(conn, page_id: int) -> list: + """Build a Notion-style breadcrumb chain (root -> page) for the header. + + Returns a list of dicts: {id, label, url, icon, menu}. The last item is the + current page (url = None). Every item has ``menu: True`` so the header can + open a sibling-navigation dropdown for it. + """ + from app.routers.board import _file_icon + chain = [] + current = page_id + seen = set() + while current and current not in seen: + seen.add(current) + row = conn.execute( + "SELECT id, title, parent_id, parent_section, content_format " + "FROM pages WHERE id=? AND deleted_at IS NULL", + (current,), + ).fetchone() + if not row: + break + is_folder = row["parent_section"] == "Workspace" + title = row["title"] or "Untitled" + chain.insert(0, { + "id": row["id"], + "label": title, + "url": None, + "icon": "folder" if is_folder else _file_icon(title, row["content_format"]), + "menu": True, + }) + current = row["parent_id"] + # All items except the current page are navigable links. + for i, item in enumerate(chain): + if i < len(chain) - 1: + item["url"] = f"/pages/{item['id']}" + return chain + + + + + + +def _get_active_workspace(request: Request, user_id: int = None) -> dict | None: + """Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None.""" + ws_id = request.cookies.get(WORKSPACE_COOKIE) + if ws_id: + try: + with get_conn() as conn: + ws = conn.execute("SELECT * FROM workspaces WHERE id=?", + (int(ws_id),)).fetchone() + if ws: + ws_dict = dict(ws) + # Verify ownership — only return if it belongs to the current user + if user_id is None or ws_dict.get("owner_id") == user_id: + return ws_dict + except (ValueError, Exception): + pass + # Fallback: first workspace owned by this user + if user_id: + with get_conn() as conn: + ws = conn.execute( + "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", + (user_id,) + ).fetchone() + if ws: + return dict(ws) + return None + + + + + + +def _sanitize_id(block_id: str) -> str: + """Sanitize a block id for use as an HTML anchor (only alnum kept).""" + if not block_id: + return "" + return "".join(ch for ch in str(block_id) if ch.isalnum()) + + + + + + +def _render_blocks_public(blocks: list, titles: dict | None = None) -> str: + """Render FlowDeck blocks as plain HTML for public pages. + + v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns + ``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links. + """ + html_parts = [] + + def _wiki(c: str) -> str: + if titles and ("[[fdpage:" in c or "[[fddate:" in c): + from app.services.wiki_links import resolve_tokens_html + return resolve_tokens_html(c, titles) + return c + + for b in blocks: + t = b.get("type", "paragraph") + c = _wiki(b.get("content", "") or "") + if t == "heading_1": + html_parts.append(f'{c}' + ) + elif t == "table_of_contents": + toc = [ + x for x in blocks + if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip() + ] + if toc: + items = [] + for h in toc: + lvl = int(h["type"].split("_")[-1]) + items.append( + f'
'
+ f'{lang_label}'
+ f'{c}'
+ )
+ elif t == "divider":
+ html_parts.append('{c}
') + return "\n".join(html_parts) + + +# ═══════════ Library page actions API ═══════════ + + diff --git a/app/routers/dashboard/account_api.py b/app/routers/dashboard/account_api.py new file mode 100644 index 0000000..3f40034 --- /dev/null +++ b/app/routers/dashboard/account_api.py @@ -0,0 +1,77 @@ +"""FlowDeck — Dashboard : account_api. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request + +from app.db import get_conn + +from ._common import _require_user_id + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +@router.put("/api/user/profile") +def update_profile(request: Request, body: dict = Body(default={})): + full_name = body.get("full_name", "").strip() + uid = _require_user_id(request) + with get_conn() as conn: + conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.put("/api/user/password") +def update_password(request: Request, body: dict = Body(default={})): + from app.password_utils import hash_password, verify_password + password = body.get("password", "").strip() + if len(password) < 6: + return {"error": "Password must be at least 6 characters"} + uid = _require_user_id(request) + # A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp). + current = body.get("current_password", "") + with get_conn() as conn: + row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone() + if not row or not verify_password(current, row["password_hash"]): + raise HTTPException(403, "Current password is incorrect") + conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/user/token") +def generate_token(request: Request): + import secrets + uid = _require_user_id(request) + token = secrets.token_hex(32) + with get_conn() as conn: + conn.execute( + "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + (uid, token), + ) + conn.commit() + return {"token": f"fd_{token}"} + + + + +@router.delete("/api/user/forge/{provider}") +def disconnect_forge(request: Request, provider: str): + uid = _require_user_id(request) + with get_conn() as conn: + conn.execute( + "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) + ) + conn.commit() + return {"status": "ok"} diff --git a/app/routers/dashboard/account_settings.py b/app/routers/dashboard/account_settings.py new file mode 100644 index 0000000..ce3f518 --- /dev/null +++ b/app/routers/dashboard/account_settings.py @@ -0,0 +1,439 @@ +"""FlowDeck — Dashboard : settings. + +Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.db import get_conn + +from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["dashboard"]) + + + + +# ═══════════ Settings Page ═══════════ + +@router.get("/settings", response_class=HTMLResponse) +def app_settings_page(request: Request): + """Settings & configuration page.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, []) + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return RedirectResponse("/auth/login?provider=local", status_code=302) + ctx = {**sidebar, "user": user} + # Pass active workspace for breadcrumb nav menu + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ctx["nav_workspace_id"] = ws["id"] if ws else 0 + template = env.get_template("settings.html") + return template.render(**ctx) + + + + +@router.post("/api/settings/avatar") +async def upload_avatar(request: Request): + """Upload a user avatar image.""" + import os + import uuid + from pathlib import Path + form = await request.form() + file = form.get("file") + if not file: + return {"error": "No file"}, 400 + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return {"error": "Not authenticated"}, 401 + # Save to data/avatars + avatars_dir = Path("/data/avatars") + avatars_dir.mkdir(parents=True, exist_ok=True) + ext = os.path.splitext(file.filename)[1] or ".png" + filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}" + filepath = avatars_dir / filename + content = await file.read() + filepath.write_bytes(content) + # Update user avatar_url + avatar_url = f"/api/settings/avatar/{filename}" + with get_conn() as conn: + conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"])) + conn.commit() + return {"avatar_url": avatar_url} + + + + +@router.get("/api/settings/avatar/{filename:path}") +def serve_avatar_file(filename: str): + """Serve an uploaded avatar image file.""" + from pathlib import Path + + from fastapi.responses import FileResponse + # A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette + # accepte les `/`, donc `..%2f` ressortirait du dossier avatars. + base_dir = Path("/data/avatars").resolve() + filepath = (base_dir / filename).resolve() + try: + filepath.relative_to(base_dir) + except ValueError: + return JSONResponse({"error": "Path traversal denied"}, status_code=403) + if not filepath.is_file(): + return JSONResponse({"error": "Not found"}, status_code=404) + return FileResponse(filepath) + + + + +@router.get("/api/avatar/{user_id:int}") +def get_avatar(user_id: int): + """Redirect to the user's avatar.""" + with get_conn() as conn: + row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() + if row and row["avatar_url"]: + return RedirectResponse(row["avatar_url"], status_code=302) + return JSONResponse({"error": "No avatar"}, status_code=404) + + + + +@router.post("/api/settings/avatar-color") +def set_avatar_color(request: Request, body: dict = Body(default={})): + """Set the user's avatar background color.""" + color = body.get("color", "#3A3A3A") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return {"error": "Not authenticated"}, 401 + with get_conn() as conn: + conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"])) + conn.commit() + return {"status": "ok", "color": color} + + +# ═══════════ Tag Management API (per-user) ═══════════ + + + + +# ═══════════ Tag Management API (per-user) ═══════════ + +@router.post("/api/settings/tags") +def create_tag_global(request: Request, body: dict = Body(default={})): + """Create a tag for the current user.""" + tag_name = body.get("name", "").strip().lower() + color = body.get("color", "#787774") + uid = _get_user_id(request) + if not tag_name or not uid: + return {"error": "Tag name required"}, 400 + with get_conn() as conn: + tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() + if tag: + conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"])) + conn.commit() + return {"tag": {"id": tag["id"], "name": tag_name, "color": color}} + cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid)) + conn.commit() + return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}} + + + + +@router.put("/api/settings/tags/{tag_id:int}") +def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})): + """Update a tag (name or color) — only if owned by user.""" + uid = _get_user_id(request) + with get_conn() as conn: + if "name" in body: + conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid)) + if "color" in body: + conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.delete("/api/settings/tags/{tag_id:int}") +def delete_tag_global(tag_id: int, request: Request): + """Delete a tag — only if owned by user.""" + uid = _get_user_id(request) + with get_conn() as conn: + conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,)) + conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid)) + conn.commit() + return {"status": "ok"} + + + + +@router.get("/api/settings/tags/all") +def list_all_tags_global(request: Request): + """List current user's tags with counts.""" + uid = _get_user_id(request) + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count " + "FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id " + "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", + (uid,), + ).fetchall() + return JSONResponse( + {"tags": [dict(r) for r in rows]}, + headers={"Cache-Control": "no-store"}, + ) + + +# ═══════════ Workspace Tags API ═══════════ + + + + +# ═══════════ Workspace Tags API ═══════════ + +@router.get("/api/local-workspace/tags") +def list_tags(request: Request): + """List ALL user tags with counts scoped to the active workspace.""" + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = ws["id"] if ws else None + uid = _get_user_id(request) + if not ws_id: + return {"tags": []} + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color, " + "(SELECT COUNT(*) FROM page_tags pt " + " JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " + " WHERE pt.tag_id = t.id) as count " + "FROM tags t WHERE t.user_id = ? ORDER BY t.name", + (ws_id, uid), + ).fetchall() + return JSONResponse( + {"tags": [dict(r) for r in rows]}, + headers={"Cache-Control": "no-store"}, + ) + + + + +@router.get("/api/local-workspace/items/{item_id:int}/tags") +def get_item_tags(item_id: int): + """Get tags for a specific item.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color FROM tags t " + "JOIN page_tags pt ON pt.tag_id = t.id " + "WHERE pt.page_id = ? ORDER BY t.name", + (item_id,), + ).fetchall() + return {"tags": [dict(r) for r in rows]} + + + + +@router.post("/api/local-workspace/items/{item_id:int}/tags") +def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})): + """Add a tag to an item (creates tag if new, scoped to user).""" + tag_name = body.get("name", "").strip().lower() + tag_color = body.get("color", "#787774") + uid = _get_user_id(request) + if not tag_name: + return {"error": "Tag name required"}, 400 + + with get_conn() as conn: + # Get or create tag (per user) + tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() + if not tag: + cursor = conn.execute( + "INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid) + ) + conn.commit() + tag_id = cursor.lastrowid + tag = {"id": tag_id, "name": tag_name, "color": tag_color} + else: + tag_id = tag["id"] + + # Link tag to page (ignore duplicate) + try: + conn.execute( + "INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", + (item_id, tag_id), + ) + conn.commit() + except Exception: + logger.exception("add_item_tag") + + return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} + + + + +@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") +def remove_item_tag(item_id: int, tag_id: int): + """Remove a tag from an item.""" + with get_conn() as conn: + conn.execute( + "DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?", + (item_id, tag_id), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.get("/api/local-workspace/tags/search") +def search_by_tags(request: Request, tags: str = ""): + """Search items by tags (comma-separated).""" + ws = _get_active_workspace(request, user_id=_get_user_id(request)) + ws_id = ws["id"] if ws else None + if not ws_id: + return {"items": []} + tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()] + if not tag_names: + return {"items": []} + with get_conn() as conn: + placeholders = ",".join("?" for _ in tag_names) + rows = conn.execute( + f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, " + f"p.content, p.created_at, p.updated_at " + f"FROM pages p " + f"JOIN page_tags pt ON pt.page_id = p.id " + f"JOIN tags t ON t.id = pt.tag_id " + f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL " + f"ORDER BY p.updated_at DESC", + tag_names + [ws_id], + ).fetchall() + items = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + size = len(r["content"] or "") + items.append({ + "id": r["id"], + "name": r["title"] or "Untitled", + "is_folder": is_folder, + "content_format": r["content_format"], + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "size": size, + "size_display": _format_size(size), + }) + return {"items": items} + + +# ── Account update ── + + + + +# ── Account update ── +@router.put("/api/settings/account") +def update_account(request: Request, body: dict = Body(default={})): + """Update current user's profile: full_name, login, email, password.""" + from app.password_utils import hash_password + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + with get_conn() as conn: + uid = user["id"] + if "full_name" in body: + conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid)) + if "login" in body: + new_login = body["login"].strip() + if new_login and new_login != user.get("login"): + existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone() + if existing: + return JSONResponse({"error": "Username already taken"}, status_code=409) + conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid)) + if "email" in body: + conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid)) + if "password" in body and body["password"].strip(): + pw = body["password"].strip() + if len(pw) < 6: + return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) + conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid)) + conn.commit() + row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + user_data = dict(row) + # Refresh session cookie with updated data (keeps the same session id) + cookie = request.cookies.get("flowdeck_session", "") + new_session = SessionManager.refresh_session(cookie, user_data, request) + response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}}) + response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/") + return response + + +# ═══════════ Sidebar Refresh API ═══════════ + + + + +# ═══════════ Sidebar Refresh API ═══════════ + +@router.get("/api/sidebar/workspace-tree") +def sidebar_workspace_tree(request: Request): + """Return the sidebar workspace tree as HTML fragment. + + Called by appState().refreshSidebarTree() after CRUD operations + in the main content area to keep the sidebar in sync. + """ + from app.routers.board import _load_workspace_pages + from app.templating import ENV + + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return HTMLResponse("") + + ws_cookie = request.cookies.get("flowdeck_workspace", "") + if not ws_cookie: + return HTMLResponse('Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.
+FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.
+Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.
+Organize your work into separate workspaces. Each has its own pages and files.
+Connect your Gitea account to access repositories directly from FlowDeck.
+Share pages with collaborators or publish them to the web.
+/p/your-slugFind all your content in one place with powerful filtering.
+Install FlowDeck as an app and keep working without a connection.
+
+FlowDeck supports three authentication methods:
+Local Email + password — create an account on the login page.
+Gitea OAuth Login with your Gitea account. Your repos appear as workspaces.
+GitHub OAuth Login or link your GitHub account in Settings → Integrations.
+Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
+
+SSO Enterprise SSO (v6.7.0) — sign in with your organization account.
+For administrators: open Settings → Admin → SSO / Enterprise and pick a provider:
+• SAML 2.0 — paste the IdP Entity ID, SSO URL and signing certificate, then give the IdP this
+/auth/saml/metadata link (it contains the SP Entity ID, ACS URL and certificate).
+• OpenID Connect — paste the Issuer URL, Client ID and Client Secret (PKCE is used, scopes default to openid profile email).
+• Provisioning — accounts are created automatically on first login, groups from the IdP map to workspace roles,
+and SSO only disables local login (admins keep their local door). Every attempt is audited in
+Settings → Admin → SSO / Enterprise (login history).
+
+FlowDeck is a Progressive Web App: pages you visited stay available offline and your
+edits are saved locally, then synchronised when the connection returns.
+Install: open your browser menu and choose Install app (Chrome/Edge) or
+Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
+Offline editing: while offline, the editor stores changes in the browser
+(IndexedDB) and shows an offline banner with the number of pending changes. A
+⟳ icon appears next to pages that have unsynced edits.
+Reconnection: the queue is replayed automatically (and via Background Sync).
+A spinner badge appears while syncing, followed by a confirmation toast.
+Conflicts: if a page changed on the server, the latest edit wins and a notice is
+shown. If a page was deleted server-side, your offline copy is recreated as an orphan
+page. If a page with the same title already exists, the offline copy is renamed
+“Title (copie offline)”.
+
+FlowDeck exposes a full REST API under /api/v2 for third-party integrations.
+Auth: create a token in Settings → API tokens, then send it as
+Authorization: Bearer <token>. Tokens carry scopes
+read, write or admin (a higher scope implies the lower ones).
+Features: CRUD on collections, pages, properties, views, comments, notifications,
+favorites, tags, sharing, sprints and templates; pagination (?limit=&offset= +
+X-Total-Count), filters (filter[prop]=value), sorting, full-text search
+(/api/v2/search), idempotency (Idempotency-Key) and RFC 7807 error bodies.
+Reference: interactive OpenAPI docs at /docs
+(also /redoc, docs/openapi-v2.json).
+
+• Toggle the sidebar with the « button in the top-left corner.
+• Switch between workspaces using the dropdown menu in the sidebar header.
+• The Private section appears when a remote workspace is active — files here stay local.
+• Hover over any sidebar item to see action buttons (favorite, share, delete).
+• Use Ctrl+Click or Shift+Click to multi-select items in the sidebar.
+
{page.get('content', '')}
" + elif page.get("content"): + # Plain text / markdown + text = page["content"] + content_html = f"{text}"
+
+ template = env.get_template("public_page.html")
+ return template.render(
+ title=page["title"] or "Untitled",
+ content_html=content_html,
+ updated_at=page.get("updated_at", ""),
+ created_at=page.get("created_at", ""),
+ cover_url=page.get("cover_url", ""),
+ page_icon=page.get("page_icon", ""),
+ )
diff --git a/app/routers/dashboard/workspace.py b/app/routers/dashboard/workspace.py
new file mode 100644
index 0000000..d6ad685
--- /dev/null
+++ b/app/routers/dashboard/workspace.py
@@ -0,0 +1,321 @@
+"""FlowDeck — Dashboard : workspace.
+
+Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Body, Query, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from app.auth.session import SessionManager
+from app.db import get_conn
+from app.services.gitea_client import get_user_gitea_client, gitea
+
+from ._common import _get_active_workspace, _get_user_id, _sidebar_data
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["dashboard"])
+
+
+
+
+@router.get("/", response_class=HTMLResponse)
+async def dashboard(
+ request: Request,
+ search: str = Query(default=""),
+ show_archived: bool = Query(default=False),
+):
+ """Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+
+ # ── Not authenticated → show landing page ──
+ if not user:
+ # Allow through if DB is empty (fresh install)
+ try:
+ with get_conn() as conn:
+ count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
+ if count == 0:
+ from app.templating import ENV
+ env = ENV
+ template = env.get_template("landing.html")
+ return template.render()
+ except Exception:
+ logger.exception("dashboard")
+ from app.templating import ENV
+ env = ENV
+ template = env.get_template("landing.html")
+ return template.render()
+
+ # ── Authenticated ──
+ user_id = user.get("id", 1)
+ has_gitea = False
+ try:
+ with get_conn() as conn:
+ tok = conn.execute(
+ "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
+ (user_id,),
+ ).fetchone()
+ has_gitea = bool(tok)
+ except Exception:
+ logger.exception("dashboard")
+
+ if not has_gitea:
+ # Check if user has any workspace
+ try:
+ with get_conn() as conn:
+ ws_count = conn.execute(
+ "SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
+ ).fetchone()[0]
+ if ws_count == 0:
+ # v5.2.0: first-launch → onboarding wizard
+ return RedirectResponse("/welcome", status_code=302)
+ except Exception:
+ logger.exception("dashboard")
+ return RedirectResponse("/local-workspace", status_code=302)
+
+ # ── Gitea user → full dashboard ──
+ try:
+ repos = await gitea.get_user_repos(page=1, limit=50)
+ if search:
+ q = search.lower()
+ repos = [r for r in repos if q in r.get("full_name", "").lower()
+ or q in (r.get("description") or "").lower()]
+ if not show_archived:
+ repos = [r for r in repos if not r.get("archived", False)]
+ repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
+ except Exception as e:
+ logger.error("Dashboard error: %s", e)
+ repos = []
+
+ from app.templating import ENV
+ env = ENV
+ sidebar = _sidebar_data(request, repos)
+ template = env.get_template("dashboard.html")
+ return template.render(request=request, repos=repos, search=search,
+ show_archived=show_archived, **sidebar)
+
+
+# ═══════════ Workspace ═══════════
+
+
+
+
+# ═══════════ Workspace ═══════════
+
+@router.get("/workspace", response_class=HTMLResponse)
+def workspace_page(request: Request):
+ """Unified workspace showing all projects."""
+ from app.templating import ENV
+ env = ENV
+ sidebar = _sidebar_data(request, [])
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user:
+ return RedirectResponse("/auth/login?provider=local", status_code=302)
+ ctx = {**sidebar, "user": user}
+ # Pass active workspace for breadcrumb nav menu
+ ws = _get_active_workspace(request, user_id=_get_user_id(request))
+ ctx["nav_workspace_id"] = ws["id"] if ws else 0
+ template = env.get_template("workspace.html")
+ return template.render(**ctx)
+
+
+
+
+@router.get("/gitea-workspace", response_class=HTMLResponse)
+def gitea_workspace_page(request: Request):
+ """Gitea workspace — browse repo files."""
+ import json
+
+ from app.templating import ENV
+ env = ENV
+ sidebar = _sidebar_data(request, [])
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user:
+ return RedirectResponse("/auth/login?provider=local", status_code=302)
+ owner = request.query_params.get("owner", "")
+ repo = request.query_params.get("repo", "")
+ ws_key = f"{owner}/{repo}" if owner and repo else ""
+ ws_name = ws_key or "Gitea Workspace"
+ # Auto-create local workspace mirror for storing local files
+ local_ws_id = None
+ if ws_key:
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
+ (user["id"], ws_key, "%gitea_repo%")
+ ).fetchone()
+ if existing:
+ local_ws_id = existing["id"]
+ else:
+ c = conn.execute(
+ "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
+ (ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
+ )
+ local_ws_id = c.lastrowid
+ conn.execute(
+ "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
+ (local_ws_id, user["id"], "admin")
+ )
+ conn.commit()
+ ctx = {
+ **sidebar,
+ "user": user,
+ "active_ws_name": ws_name,
+ "workspace_key": ws_key,
+ "gitea_workspace": True, # always true on this page
+ "gitea_owner": owner,
+ "gitea_repo": repo,
+ "workspace_name": ws_name,
+ "workspace_initial": repo[0].upper() if repo else "G",
+ "owner": owner,
+ "repo": repo,
+ "nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
+ }
+ template = env.get_template("gitea_workspace.html")
+ resp = HTMLResponse(content=template.render(**ctx))
+ resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
+ return resp
+
+
+
+
+@router.get("/api/workspace/projects")
+async def list_workspace_projects(request: Request):
+ """List all projects: built-in + Gitea + GitHub.
+ Uses the user's own Gitea token if connected, not the global admin token."""
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+
+ builtin = []
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
+ ).fetchall()
+ # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
+ counts = {}
+ if rows:
+ for c in conn.execute(
+ "SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
+ ",".join("?" * len(rows))
+ ),
+ [r["id"] for r in rows],
+ ).fetchall():
+ counts[c["parent_id"]] = c["c"]
+ for r in rows:
+ builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
+
+ gitea_repos = []
+ # Use per-user token if available, otherwise return empty
+ user_gitea = get_user_gitea_client(request) if user else None
+ if user_gitea:
+ try:
+ repos = await user_gitea.get_user_repos(page=1, limit=50)
+ for repo in repos:
+ gitea_repos.append({
+ "id": str(repo.get("id", "")),
+ "name": repo.get("name", ""),
+ "full_name": repo.get("full_name", ""),
+ "description": repo.get("description", ""),
+ "html_url": repo.get("html_url", ""),
+ "language": repo.get("language", ""),
+ "forge": "gitea",
+ })
+ except Exception:
+ logger.exception("list_workspace_projects")
+
+ return {"builtin": builtin, "gitea": gitea_repos, "github": []}
+
+
+
+
+@router.post("/api/workspace/projects")
+def create_workspace_project(request: Request, body: dict = Body(default={})):
+ name = body.get("name", "").strip()
+ if not name:
+ return {"error": "Name required"}
+ with get_conn() as conn:
+ cursor = conn.execute(
+ "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
+ (name,),
+ )
+ conn.commit()
+ pid = cursor.lastrowid
+ return {"id": pid, "name": name, "forge": "builtin"}
+
+
+# ═══════════ Workspace Members API ═══════════
+
+
+
+
+# ═══════════ Workspace Members API ═══════════
+
+@router.get("/api/workspace/{ws_id:int}/members")
+def list_members(request: Request, ws_id: int):
+ """List all members of a workspace."""
+ with get_conn() as conn:
+ rows = conn.execute(
+ """SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at
+ FROM workspace_members wm JOIN users u ON u.id = wm.user_id
+ WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,)
+ ).fetchall()
+ return {"members": [dict(r) for r in rows]}
+
+
+
+
+@router.post("/api/workspace/{ws_id:int}/members")
+def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
+ """Invite a user to a workspace by email."""
+ email = body.get("email", "").strip()
+ role = body.get("role", "editor")
+ if role not in ("owner", "admin", "editor", "viewer"):
+ return {"error": "Invalid role"}, 400
+ with get_conn() as conn:
+ user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
+ if not user:
+ return {"error": "User not found"}, 404
+ try:
+ conn.execute(
+ "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
+ (ws_id, user["id"], role),
+ )
+ conn.commit()
+ except Exception:
+ return {"error": "Already a member"}, 409
+ return {"status": "ok", "user_id": user["id"], "role": role}
+
+
+
+
+@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
+def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
+ """Change a member's role."""
+ role = body.get("role", "editor")
+ if role not in ("owner", "admin", "editor", "viewer"):
+ return {"error": "Invalid role"}
+ with get_conn() as conn:
+ conn.execute(
+ "UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
+ (role, ws_id, user_id),
+ )
+ conn.commit()
+ return {"status": "ok"}
+
+
+
+
+@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
+def remove_member(request: Request, ws_id: int, user_id: int):
+ """Remove a member from a workspace."""
+ with get_conn() as conn:
+ conn.execute(
+ "DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
+ (ws_id, user_id),
+ )
+ conn.commit()
+ return {"status": "ok"}
+
+
+# ═══════════ Local Workspace (file/folder CRUD) ═══════════
diff --git a/app/routers/dashboard/workspaces.py b/app/routers/dashboard/workspaces.py
new file mode 100644
index 0000000..fb7b4d8
--- /dev/null
+++ b/app/routers/dashboard/workspaces.py
@@ -0,0 +1,131 @@
+"""FlowDeck — Dashboard : workspaces.
+
+Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Body, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+
+from app.auth.session import SessionManager
+from app.db import get_conn
+
+from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["dashboard"])
+
+
+
+
+@router.get("/workspaces", response_class=HTMLResponse)
+def workspaces_page(request: Request):
+ """Workspaces list page."""
+ from app.templating import ENV
+ env = ENV
+ sidebar = _sidebar_data(request, [], include_workspace=False)
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user:
+ return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
+ ctx = {**sidebar, "user": user}
+ # Pass active workspace for breadcrumb nav menu (null on workspaces home)
+ ws = _get_active_workspace(request, user_id=_get_user_id(request))
+ ctx["nav_workspace_id"] = ws["id"] if ws else 0
+ template = env.get_template("workspaces.html")
+ return template.render(**ctx)
+
+
+
+
+@router.get("/api/workspaces")
+def list_workspaces(request: Request):
+ """List all workspaces for the current user."""
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ uid = user["id"] if user and user.get("id") else 1
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
+ "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
+ (uid,),
+ ).fetchall()
+ workspaces = []
+ for r in rows:
+ d = dict(r)
+ workspaces.append(d)
+ active = _get_active_workspace(request, user_id=_get_user_id(request))
+ return {"workspaces": workspaces, "active_id": active["id"] if active else None}
+
+
+
+
+@router.post("/api/workspaces")
+def create_workspace(request: Request, body: dict = Body(default={})):
+ """Create a new workspace."""
+ name = body.get("name", "New Workspace").strip()
+ if not name:
+ return {"error": "Name required"}
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ uid = user["id"] if user and user.get("id") else 1
+ with get_conn() as conn:
+ # Ensure user exists (FK constraint)
+ uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
+ if not uid_ok:
+ conn.execute(
+ "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
+ (uid, user.get("login", "admin") if user else "admin",
+ user.get("full_name", "Admin") if user else "Admin"),
+ )
+ cursor = conn.execute(
+ "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
+ (name, uid),
+ )
+ ws_id = cursor.lastrowid
+ # Add owner as member
+ conn.execute(
+ "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
+ (ws_id, uid),
+ )
+ conn.commit()
+ return {"id": ws_id, "name": name}
+
+
+
+
+@router.put("/api/workspaces/{ws_id:int}")
+def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
+ """Rename a workspace."""
+ name = body.get("name", "").strip()
+ if not name:
+ return {"error": "Name required"}
+ with get_conn() as conn:
+ conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
+ conn.commit()
+ return {"status": "ok"}
+
+
+
+
+@router.delete("/api/workspaces/{ws_id:int}")
+def delete_workspace(request: Request, ws_id: int):
+ """Delete a workspace and all its pages."""
+ with get_conn() as conn:
+ conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
+ conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
+ conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
+ conn.commit()
+ return {"status": "ok"}
+
+
+
+
+@router.post("/api/workspaces/{ws_id:int}/select")
+def select_workspace(request: Request, ws_id: int):
+ """Set the active workspace via cookie."""
+ response = JSONResponse({"status": "ok", "workspace_id": ws_id})
+ response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
+ return response
+
+
+# ═══════════ Settings Page ═══════════
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json
index 2fdaa49..813cf0a 100644
--- a/docs/openapi-v2.json
+++ b/docs/openapi-v2.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
- "version": "7.29.0"
+ "version": "7.30.0"
},
"paths": {
"/auth/register": {