From cb4465255474be7d3cad3e1fee9da1056f29458b Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 09:34:02 -0400 Subject: [PATCH 01/59] fix: Gitea workspace affiche owner/repo dans le titre et topbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le titre de page (onglet) : 'bruno/flowdeck' au lieu de 'Gitea Workspace' - Le topbar breadcrumb : owner/repo correctement peuplรฉ - workspace_name + workspace_initial passรฉs au template --- app/routers/dashboard.py | 11 ++++++++++- app/templates/gitea_workspace.html | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 4181c35..8ac9325 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -414,7 +414,16 @@ async def gitea_workspace_page(request: 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} + owner = request.query_params.get("owner", "") + repo = request.query_params.get("repo", "") + ctx = { + **sidebar, + "user": user, + "workspace_name": f"{owner}/{repo}" if owner and repo else "Gitea Workspace", + "workspace_initial": repo[0].upper() if repo else "G", + "owner": owner, + "repo": repo, + } template = env.get_template("gitea_workspace.html") return template.render(**ctx) diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index 8c56aff..b30b807 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block page_title %}Gitea Workspace{% endblock %} +{% block page_title %}{{ workspace_name }}{% endblock %} {% block page_icon %}๐Ÿ”—{% endblock %} {% block topbar %} From 8cca247fcddfe999361e52a6ac80f36dbd53482e Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 09:34:38 -0400 Subject: [PATCH 02/59] =?UTF-8?q?fix:=20browser=20tab=20title=20uses=20pag?= =?UTF-8?q?e=5Ftitle=20block=20=E2=80=94=20refl=C3=A8te=20le=20workspace?= =?UTF-8?q?=20actif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlowDeck โ†’ FlowDeck โ€” {% block page_title %}Home{% endblock %} Maintenant l'onglet navigateur affiche: - 'FlowDeck โ€” Home' (page d'accueil) - 'FlowDeck โ€” bruno/flowdeck' (workspace Gitea) - 'FlowDeck โ€” Mon workspace local' (workspace local) --- app/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/base.html b/app/templates/base.html index 326c932..86774c0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,7 +3,7 @@ - FlowDeck + FlowDeck โ€” {% block page_title %}Home{% endblock %} From e2043123f06eb3778ef21d3a558d63ff0df8a3bb Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 09:35:52 -0400 Subject: [PATCH 03/59] =?UTF-8?q?fix:=20title=5Fprefix=20block=20for=20bro?= =?UTF-8?q?wser=20tab=20=E2=80=94=20=C3=A9vite=20conflit=20Jinja2=20page?= =?UTF-8?q?=5Ftitle=20double?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erreur: block 'page_title' defined twice โ†’ TemplateAssertionError Fix: title_prefix (nouveau bloc) pour , page_title (existant) pour Alpine 10 templates mis ร  jour avec title_prefix. --- app/templates/accounts.html | 1 + app/templates/base.html | 2 +- app/templates/board.html | 1 + app/templates/dashboard.html | 1 + app/templates/gitea_workspace.html | 1 + app/templates/library.html | 1 + app/templates/local_workspace.html | 1 + app/templates/settings.html | 1 + app/templates/trash.html | 1 + app/templates/workspace.html | 1 + app/templates/workspaces.html | 1 + 11 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/templates/accounts.html b/app/templates/accounts.html index b76fd1b..f8650c6 100644 --- a/app/templates/accounts.html +++ b/app/templates/accounts.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}๐Ÿ‘ค{% endblock %} {% block page_title %}Accounts{% endblock %} +{% block title_prefix %}Accounts{% endblock %} {% block content %} <div class="page-title-area"> diff --git a/app/templates/base.html b/app/templates/base.html index 86774c0..dae9de2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>FlowDeck โ€” {% block page_title %}Home{% endblock %} + FlowDeck โ€” {% block title_prefix %}Home{% endblock %} diff --git a/app/templates/board.html b/app/templates/board.html index 52d2246..803ed44 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}๐Ÿ“{% endblock %} {% block page_title %}Kanban board new{% endblock %} +{% block title_prefix %}Kanban board new{% endblock %} {% block content %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 090cfaf..28b0884 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}๐Ÿ {% endblock %} {% block page_title %}Home{% endblock %} +{% block title_prefix %}Home{% endblock %} {% block content %}
diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index b30b807..27a0dd5 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block page_title %}{{ workspace_name }}{% endblock %} +{% block title_prefix %}{{ workspace_name }}{% endblock %} {% block page_icon %}๐Ÿ”—{% endblock %} {% block topbar %} diff --git a/app/templates/library.html b/app/templates/library.html index 1707528..54a8fc9 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}๐Ÿ“š{% endblock %} {% block page_title %}Library{% endblock %} +{% block title_prefix %}Library{% endblock %} {% block content %}
diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index 109b0d5..d16f023 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block page_title %}{{ workspace_name }}{% endblock %} +{% block title_prefix %}{{ workspace_name }}{% endblock %} {% block page_icon %}๐Ÿ“{% endblock %} {% block topbar %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 545c2a0..795a8b0 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block page_title %}Settings{% endblock %} +{% block title_prefix %}Settings{% endblock %} {% block page_icon %}โš™๏ธ{% endblock %} {% block topbar %} diff --git a/app/templates/trash.html b/app/templates/trash.html index 0b20ad4..c74d217 100644 --- a/app/templates/trash.html +++ b/app/templates/trash.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block page_icon %}๐Ÿ—‘๏ธ{% endblock %} {% block page_title %}Trash{% endblock %} +{% block title_prefix %}Trash{% endblock %} {% block content %}
diff --git a/app/templates/workspace.html b/app/templates/workspace.html index 3ad1773..067db61 100644 --- a/app/templates/workspace.html +++ b/app/templates/workspace.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block page_title %}Workspace{% endblock %} +{% block title_prefix %}Workspace{% endblock %} {% block page_icon %}๐Ÿ {% endblock %} {% block topbar %} diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html index 00f5f2e..ed97241 100644 --- a/app/templates/workspaces.html +++ b/app/templates/workspaces.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block page_title %}Workspaces{% endblock %} +{% block title_prefix %}Workspaces{% endblock %} {% block page_icon %}๐Ÿ {% endblock %} {% block topbar %} From 8eb704946035e3a9bfa0bf24f7d343c7d93d7aa6 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 12:07:13 -0400 Subject: [PATCH 04/59] =?UTF-8?q?docs:=20ARCHITECTURE=20v3.0=20+=20ROADMAP?= =?UTF-8?q?=20v4.0=20=E2=80=94=20comptes,=20int=C3=A9grations,=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md: - Section 6.5: modรจle de comptes (local/gitea/github) - Matrice d'intรฉgrations & rรจgles de dรฉconnexion - 3 scรฉnarios de workspace (A/B/C) - Sidebar: rรจgles d'affichage par type de compte - Share & Publish: modale 2 tabs + schรฉma DB - Library: /library?tab= + filtres source - Trash local-only, รฉdition unifiรฉe, stockage Private - Plan de migration 5 phases ROADMAP.md: - v4.0.0 section prioritaire: Account Model, Intรฉgrations, Sidebar rules, Share/Publish, Library, ร‰dition unifiรฉe --- ARCHITECTURE.md | 257 +++++++++++++++++++++++++++++++++++++++++++++++- ROADMAP.md | 41 ++++++++ 2 files changed, 295 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 20f2168..83d6e1b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ -# Architecture FlowDeck โ€” Document Complet v2.0 +# Architecture FlowDeck โ€” Document Complet v3.0 -> **Version:** 2.0 ยท **Date:** 2026-07-10 ยท **Auteur:** Hermes-Deepin -> **Cible:** Clone Notion intรฉgrรฉ ร  Gitea avec support multi-utilisateurs +> **Version:** 3.0 ยท **Date:** 2026-07-14 ยท **Auteur:** Hermes-Deepin +> **Cible:** Clone Notion intรฉgrรฉ ร  Gitea/GitHub โ€” multi-comptes, multi-intรฉgrations --- @@ -800,6 +800,257 @@ User authorize โ”€โ”€โ†’ GET /auth/callback?code=xxx --- +## 6.5 Modรจle de comptes & intรฉgrations (v4.0) + +### 6.5.1 Types de comptes + +FlowDeck supporte 3 types de comptes, dรฉterminรฉs par `auth_method` dans la table `users` : + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ auth_method โ”‚ Login via โ”‚ Identitรฉ dans le sidebar โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ local โ”‚ email + password โ”‚ Nom local (ex: "Draco") โ”‚ +โ”‚ gitea โ”‚ OAuth2 Gitea โ”‚ Nom Gitea + badge ๐ŸฆŽ Gitea โ”‚ +โ”‚ github โ”‚ OAuth2 GitHub โ”‚ Nom GitHub + badge ๐Ÿ™ GitHub โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Rรจgle fondamentale** : Un compte local avec intรฉgration(s) reste un compte LOCAL. +Le badge OAuth (๐ŸฆŽ/๐Ÿ™) n'apparaรฎt QUE pour les comptes dont `auth_method` est le provider. + +``` +Compte LOCAL avec intรฉgrations : +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ [D] Draco โ”‚ โ† Nom local, PAS de badge +โ”‚ bruno.charest@gmail.com โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Compte GITEA (auth_method=gitea) : +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ [B] bruno [๐ŸฆŽ Gitea] โ”‚ โ† Badge visible +โ”‚ bruno@dracodev.net โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 6.5.2 Matrice des intรฉgrations + +``` +Compte principal Peut ajouter Dรฉconnexion possible +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +local Gitea โœ… Oui +local GitHub โœ… Oui +local Gitea + GitHub โœ… Les deux +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +gitea GitHub โœ… GitHub โŒ Gitea +github Gitea โœ… Gitea โŒ GitHub +``` + +**Settings โ†’ Integrations** : Affiche toutes les intรฉgrations avec bouton +Disconnect. Le provider principal (auth_method) n'a PAS de bouton Disconnect. + +### 6.5.3 Scรฉnarios de workspace + +``` +SCร‰NARIO A : Compte local pur +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +ยท Login email+password +ยท Workspaces locaux uniquement +ยท Section Private : CACHร‰E (tout est dรฉjร  local) +ยท Trash : โœ… (restauration locale) + +SCร‰NARIO B : Compte local + intรฉgration(s) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +ยท Login email+password +ยท Workspaces : locaux + Gitea + GitHub +ยท Section Private : VISIBLE si workspace distant actif +ยท Private : stockage DB locale, associรฉ user+repo +ยท Trash : โœ… (local uniquement) + +SCร‰NARIO C : Compte OAuth pur (gitea ou github) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +ยท Login via OAuth +ยท Workspaces : distants du provider principal +ยท Section Private : VISIBLE (toujours) +ยท Trash : โŒ (pas de contenu local) +ยท Intรฉgration secondaire : possible +ยท Dรฉconnexion provider principal : impossible +``` + +### 6.5.4 Sidebar โ€” rรจgles d'affichage par section + +``` +Section Scรฉnario A Scรฉnario B Scรฉnario C +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Recents โœ… (local) โœ… (tout) โœ… (tout) +Favorites โœ… (local) โœ… (tout) โœ… (tout) +Shared โœ… (local) โœ… (tout) โœ… (tout) +Published โœ… (local) โœ… (tout) โœ… (tout) +Agents โœ… โœ… โœ… +Private โŒ CACHร‰ โœ… si ws distant actif โœ… +Workspace โœ… โœ… โœ… +Trash โœ… (local) โœ… (local) โŒ +``` + +Chaque section a un bouton `โ†’ Library` qui ouvre : +``` +/library?tab=recents|favorites|shared|published|private|workspace +``` + +### 6.5.5 Systรจme Share & Publish + +Le bouton [Share] dans la topbar d'une page ouvre une modale ร  2 tabs : + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ [ Share ] [ Publish ] โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Share tab : โ”‚ +โ”‚ ยท Invite people : [email] [Invite] โ”‚ +โ”‚ ยท General access : [๐Ÿ”’ Restricted] Copy link โ”‚ +โ”‚ ยท Liste des personnes/groupes avec accรจs โ”‚ +โ”‚ โ†’ Document listรฉ dans sidebar "Shared" โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Publish tab : โ”‚ +โ”‚ ยท [Publish to web] โ†’ URL publique gรฉnรฉrรฉe โ”‚ +โ”‚ ยท URL : https://flowdeck.draco.dev/p/ โ”‚ +โ”‚ ยท [Unpublish] โ”‚ +โ”‚ โ†’ Document listรฉ dans sidebar "Published" โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 6.5.6 Stockage des fichiers Private + +Quand un workspace distant est actif, les fichiers crรฉรฉs dans la section +Private sont stockรฉs dans la **base de donnรฉes locale** (table `pages` avec +`parent_section='Private'` et `workspace=gitea:/`). + +Cela permet de : +- Prendre des notes personnelles liรฉes ร  un projet sans les committer +- Garder des brouillons avant de les pousser +- Avoir un espace de travail privรฉ mรชme sur un repo partagรฉ + +### 6.5.7 Trash โ€” rรจgles + +``` +Contenu Trash ? Comportement +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Workspace local โœ… Supprimรฉ โ†’ restaurable +Dossier/fichier local โœ… Supprimรฉ โ†’ restaurable +Fichier Gitea/GitHub โŒ Commit "delete" โ†’ pas trash +Page distante โŒ Commit "delete" โ†’ pas trash +``` + +### 6.5.8 ร‰dition de fichiers โ€” UI unifiรฉe + +``` +Fichier LOCAL Fichier DISTANT (Gitea/GitHub) +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Mรชme UI d'รฉdition โ”‚ โ”‚ Mรชme UI d'รฉdition โ”‚ +โ”‚ Mรชme rendu visuel โ”‚ โ”‚ Mรชme rendu visuel โ”‚ +โ”‚ Sauvegarde auto DB โ”‚ โ”‚ + Bouton [Commit] โ”‚ +โ”‚ Pas de commit โ”‚ โ”‚ + Champ message commit โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 6.5.9 Page Library โ€” `/library?tab=` + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Library โ”‚ +โ”‚ [Recents] [Favorites] [Shared] [Published] โ”‚ +โ”‚ [Private] [Workspace] โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Filtres : [All] [Local] [Gitea] [GitHub] โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ ๐Ÿ“„ Document X Local ยท modifiรฉ il y a 2h โ”‚ โ”‚ +โ”‚ โ”‚ ๐Ÿ“„ README.md Gitea ยท bruno/flowdeck โ”‚ โ”‚ +โ”‚ โ”‚ ๐Ÿ“„ Notes Local ยท Workspace Projet A โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 6.5.10 Schรฉma DB โ€” mises ร  jour pour v4.0 + +```sql +-- users : ajout auth_method +ALTER TABLE users ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'local'; +-- 'local' | 'gitea' | 'github' + +-- users : colonnes OAuth existantes +-- gitea_id INTEGER (NULL si pas Gitea) +-- github_id INTEGER (NULL si pas GitHub) +-- gitea_token TEXT (token intรฉgration, NULL si pas connectรฉ) +-- github_token TEXT (token intรฉgration, NULL si pas connectรฉ) + +-- pages : ajout published +ALTER TABLE pages ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT 0; +ALTER TABLE pages ADD COLUMN publish_slug TEXT; +ALTER TABLE pages ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT 0; +ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'restricted'; +-- 'restricted' | 'link' | 'public' + +-- page_shares : qui a accรจs ร  quel document +CREATE TABLE page_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + shared_with_user_id INTEGER REFERENCES users(id), + shared_with_email TEXT, + permission TEXT NOT NULL DEFAULT 'view', + -- 'view' | 'comment' | 'edit' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES users(id) +); + +-- recents : historique de consultation +CREATE TABLE recents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + page_id INTEGER NOT NULL REFERENCES pages(id), + workspace TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'local', + -- 'local' | 'gitea' | 'github' + accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, page_id) +); +``` + +### 6.5.11 Plan de migration + +``` +Phase 1 โ€” DB & Modรจles + 1. Ajouter auth_method aux users existants (auto-dรฉtecter) + 2. Ajouter is_published, is_shared, share_mode aux pages + 3. Crรฉer table page_shares + 4. Crรฉer table recents + +Phase 2 โ€” Sidebar + 1. Badge OAuth (๐ŸฆŽ/๐Ÿ™) basรฉ sur auth_method + 2. Afficher/cacher Private selon type de compte + workspace actif + 3. Boutons Library par section + 4. Trash filtrรฉ local-only + +Phase 3 โ€” Share & Publish + 1. Modale Share ร  2 tabs fonctionnelle + 2. Share via email/user โ†’ table page_shares + 3. Share via lien โ†’ share_mode='link' + 4. Publish โ†’ is_published + publish_slug + 5. Page publique servable ร  /p/ + +Phase 4 โ€” Library + 1. Page /library avec tabs + 2. Filtrage par source (local/gitea/github) + 3. Recents alimentรฉs automatiquement + +Phase 5 โ€” Intรฉgrations + 1. GitHub OAuth dรฉjร  fait (providers.py) + 2. Matrice de dรฉconnexion respectรฉe + 3. Compte local peut connecter/dรฉconnecter Gitea+GitHub +``` + +--- + ## 7. Intรฉgration Gitea ### 7.1 Flux de donnรฉes diff --git a/ROADMAP.md b/ROADMAP.md index e98f6a0..b26daea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,47 @@ Propriรฉtรฉs custom, AI keywords, sync API, 12 tables DB --- +## v4.0.0 โ€” Accounts, Integrations & Sidebar (prioritรฉ haute) + +> **Rappel** : Section 6.5 de ARCHITECTURE.md documente le design complet. + +### 4.0 Account Model +- [ ] **`auth_method`** โ€” colonne `users.auth_method โˆˆ {local, gitea, github}` +- [ ] **Auto-dรฉtection** โ€” migrer les comptes existants (gitea_token โ†’ gitea, sinon local) +- [ ] **Badge OAuth** โ€” ๐ŸฆŽ/๐Ÿ™ dans sidebar SI `auth_method` = provider +- [ ] **Pas de badge** โ€” un compte local avec intรฉgration ne montre PAS de badge + +### 4.0a Intรฉgrations & Dรฉconnexion +- [ ] **Settings page** โ€” montre toutes les intรฉgrations avec bouton Connect/Disconnect +- [ ] **Matrice dรฉconnexion** : localโ†’dรฉco ok ยท giteaโ†’dรฉco github ok ยท githubโ†’dรฉco gitea ok +- [ ] **Provider principal** โ€” pas de bouton Disconnect sur auth_method +- [ ] **Compte local** โ€” peut connecter Gitea + GitHub simultanรฉment + +### 4.0b Sidebar โ€” rรจgles par type de compte +- [ ] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub) +- [ ] **Private cachรฉ** pour Scรฉnario A (local pur) +- [ ] **Trash local-only** โ€” cachรฉ pour compte OAuth pur, restreint au contenu local +- [ ] **Boutons โ†’ Library** sur chaque section (recents, favorites, shared, publishedโ€ฆ) + +### 4.0c Share & Publish +- [ ] **Modale Share** โ€” 2 tabs (Share + Publish) fonctionnels +- [ ] **Share tab** โ€” inviter email/user, gรฉnรฉral access lien +- [ ] **Publish tab** โ€” activer/dรฉsactiver, slug gรฉnรฉrรฉ +- [ ] **Page publique** โ€” `/p/` sert le contenu publiรฉ +- [ ] **Tables DB** โ€” `page_shares`, colonnes `is_published`, `is_shared`, `share_mode` + +### 4.0d Library +- [ ] **Page `/library?tab=`** โ€” tabs Recents, Favorites, Shared, Published, Private, Workspace +- [ ] **Filtres source** โ€” All | Local | Gitea | GitHub +- [ ] **Table `recents`** โ€” tracking automatique des documents consultรฉs + +### 4.0e ร‰dition unifiรฉe +- [ ] **Mรชme UI** pour fichiers locaux ET distants +- [ ] **Bouton Commit** โ€” visible uniquement pour fichiers distants +- [ ] **Champ message** โ€” pour les commits Gitea/GitHub + +--- + ## v4.0.0 โ€” Paritรฉ Notion (Delta Analysis) > **Mรฉthodologie**: Comparaison exhaustive de FlowDeck v3.0.1 vs Notion (2025) โ€” 60+ fonctionnalitรฉs analysรฉes, 45 manquantes identifiรฉes, 30 retenues pour v4.0.0. From 802d6812128f48777cc52698bfc2acce2a5617f0 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 12:14:41 -0400 Subject: [PATCH 05/59] =?UTF-8?q?feat(v4.0):=20Settings/Integrations=20?= =?UTF-8?q?=E2=80=94=20Gitea=20+=20GitHub=20connect/disconnect=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub integration row ajoutรฉe (๐ŸฆŽ Gitea + ๐Ÿ™ GitHub) - Badge 'Primary' sur le provider principal (auth_method) - Disconnect masquรฉ pour le provider principal - authMethod, githubLinked, giteaLinked dans Alpine data - loadGithubStatus() + disconnectGithub() methods - Matrice respectรฉe: localโ†’dรฉco OK, giteaโ†’dรฉco github OK, githubโ†’dรฉco gitea OK --- app/db.py | 52 ++++++++ app/main.py | 3 +- app/routers/dashboard.py | 24 ++++ app/routers/library.py | 249 ++++++++++++++++++++++++++++++++++++ app/routers/sharing.py | 245 +++++++++++++++++++++++++++++++++++ app/templates/settings.html | 38 +++++- 6 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 app/routers/library.py create mode 100644 app/routers/sharing.py diff --git a/app/db.py b/app/db.py index ca466ec..17612b1 100644 --- a/app/db.py +++ b/app/db.py @@ -410,6 +410,58 @@ def init_db(): """) conn.commit() + # โ”€โ”€ v4.0: Account & Integrations โ”€โ”€ + # 1) auth_method on users + try: + conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'") + except sqlite3.OperationalError: + pass + # Detect existing auth: if user has a gitea_token in user_tokens โ†’ 'gitea' + conn.execute( + "UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)" + ) + conn.execute( + "UPDATE users SET auth_method='local' WHERE auth_method IS NULL" + ) + # 2) Publishing & sharing columns on pages + try: + conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass + # 3) page_shares table + conn.execute(""" + CREATE TABLE IF NOT EXISTS page_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + shared_with_user_id INTEGER REFERENCES users(id), + shared_with_email TEXT DEFAULT '', + permission TEXT NOT NULL DEFAULT 'view', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES users(id) + ) + """) + # 4) recents table + conn.execute(""" + CREATE TABLE IF NOT EXISTS recents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + page_id INTEGER NOT NULL REFERENCES pages(id), + workspace TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'local', + accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, page_id) + ) + """) + conn.commit() + @contextmanager def get_conn(): diff --git a/app/main.py b/app/main.py index c59782d..b41a5b2 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin, sharing from app.routers.gitea import router as gitea_router from app.services.gitea_client import gitea from app.services.webhook_outbound import init_webhook_tables @@ -62,6 +62,7 @@ app.include_router(webhooks.router) app.include_router(collections.router) app.include_router(my_tasks.router) app.include_router(workspace.router) +app.include_router(library.router) app.include_router(admin.router) app.include_router(gitea_router) app.include_router(public_api.router) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 8ac9325..82d91c3 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -90,6 +90,27 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: 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: + pass + return { "workspace_name": ws, "workspace_initial": initial, "active_ws_name": active_ws_name, @@ -104,6 +125,9 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: "avatar_url": avatar_url, "avatar_color": avatar_color, "workspace_key": "", + "auth_method": auth_method, + "gitea_linked": gitea_linked, + "github_linked": github_linked, } diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..8ac8b6b --- /dev/null +++ b/app/routers/library.py @@ -0,0 +1,249 @@ +"""FlowDeck โ€” Library API: recents, favorites, shared, published, private, workspace.""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request, Query +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["library"], prefix="/api/library") + +SOURCE_TYPES = {"all", "local", "gitea", "github"} + + +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 _detect_source_type(workspace: str) -> str: + """Detect source type from workspace field. + + - '' or plain name (no slash) โ†’ local + - 'owner/repo' format โ†’ gitea + - 'github:owner/repo' format โ†’ github + """ + ws = (workspace or "").strip() + if not ws: + return "local" + if ws.startswith("github:"): + return "github" + if "/" in ws: + return "gitea" + return "local" + + +def _source_badge(source_type: str) -> str: + return {"local": "Local", "gitea": "Gitea", "github": "GitHub"}.get(source_type, "Local") + + +def _build_item(db_row: dict) -> dict: + """Convert a DB row into a library item dict.""" + workspace = db_row.get("workspace", "") or "" + source_type = _detect_source_type(workspace) + # Extract workspace display name + if source_type == "gitea": + workspace_name = workspace # "owner/repo" + elif source_type == "github": + workspace_name = workspace.replace("github:", "", 1) + else: + workspace_name = workspace or "Private" + + # URL construction + page_id = db_row["id"] + if source_type == "gitea": + parts = workspace.split("/", 1) + owner = parts[0] if len(parts) > 0 else "" + repo = parts[1] if len(parts) > 1 else "" + url = f"/gitea-workspace?owner={owner}&repo={repo}" + elif source_type == "github": + gh_ws = workspace.replace("github:", "", 1) + parts = gh_ws.split("/", 1) + owner = parts[0] if len(parts) > 0 else "" + repo = parts[1] if len(parts) > 1 else "" + url = f"/gitea-workspace?owner={owner}&repo={repo}" + else: + url = f"/pages/{page_id}" + + return { + "id": page_id, + "title": db_row.get("title") or "Untitled", + "icon": "๐Ÿ“„", + "source_type": source_type, + "source_badge": _source_badge(source_type), + "workspace_name": workspace_name, + "updated_at": db_row.get("updated_at", ""), + "url": url, + } + + +def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]: + """Add WHERE clause for source_type filter. Mutates params in place.""" + if source_type == "local": + query += " AND (workspace = '' OR workspace NOT LIKE '%/%' AND workspace NOT LIKE 'github:%')" + elif source_type == "gitea": + query += " AND workspace LIKE '%/%' AND workspace NOT LIKE 'github:%'" + elif source_type == "github": + query += " AND workspace LIKE 'github:%'" + # 'all' โ†’ no filter + return query, params + + +# โ”€โ”€ Tab endpoints โ”€โ”€ + + +@router.get("/recents") +async def library_recents( + request: Request, + source_type: str = Query(default="all"), +): + """Recent pages ordered by updated_at DESC.""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + uid = _get_user_id(request) + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/favorites") +async def library_favorites( + request: Request, + source_type: str = Query(default="all"), +): + """Favorite pages for the current user.""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + uid = _get_user_id(request) + query = ( + "SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f " + "JOIN pages p ON p.id = f.page_id " + "WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" + ) + params: list = [uid] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY f.position" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/shared") +async def library_shared( + request: Request, + source_type: str = Query(default="all"), +): + """Pages shared with others (share_mode != 'private').""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE share_mode != 'private' AND parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/published") +async def library_published( + request: Request, + source_type: str = Query(default="all"), +): + """Published pages (published = 1).""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE published = 1 AND parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/private") +async def library_private( + request: Request, + source_type: str = Query(default="all"), +): + """Private pages (parent_section = 'Private').""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE parent_section = 'Private' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/workspace") +async def library_workspace( + request: Request, + source_type: str = Query(default="all"), +): + """Pages from workspaces (non-empty workspace field).""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE workspace != '' AND parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} diff --git a/app/routers/sharing.py b/app/routers/sharing.py new file mode 100644 index 0000000..c545a18 --- /dev/null +++ b/app/routers/sharing.py @@ -0,0 +1,245 @@ +"""FlowDeck โ€” Sharing, Publishing & Recents API (v4.0).""" +from __future__ import annotations + +import logging +import re +import unicodedata +from datetime import datetime + +from fastapi import APIRouter, Request, HTTPException + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["sharing"], prefix="/api") + + +def _require_auth(request: Request) -> dict: + """Require an authenticated session. Returns user dict or raises 401.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + +def _slugify(title: str) -> str: + """Generate a URL-safe slug from a page title.""" + slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^\w\s-]", "", slug.lower()) + slug = re.sub(r"[-\s]+", "-", slug).strip("-") + return slug or "untitled" + + +# โ”€โ”€ Page Sharing โ”€โ”€ + + +@router.post("/pages/{page_id}/share") +async def share_page(page_id: int, request: Request): + """Invite a user or email to a page.""" + user = _require_auth(request) + body = await request.json() if request.headers.get("content-type") else {} + target_user_id = body.get("user_id") + email = body.get("email", "") + permission = body.get("permission", "view") + + if permission not in ("view", "comment", "edit"): + raise HTTPException(400, "Invalid permission. Use view, comment, or edit.") + + if not target_user_id and not email: + raise HTTPException(400, "Provide user_id or email to share with.") + + with get_conn() as conn: + # Verify page exists + page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + # Verify target user exists if user_id given + if target_user_id: + target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone() + if not target: + raise HTTPException(404, "Target user not found") + + cur = conn.execute( + """INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) + VALUES (?, ?, ?, ?, ?)""", + (page_id, target_user_id, email, permission, user["id"]), + ) + conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) + conn.commit() + + return { + "id": cur.lastrowid, + "page_id": page_id, + "shared_with_user_id": target_user_id, + "shared_with_email": email, + "permission": permission, + "status": "shared", + } + + +@router.delete("/pages/{page_id}/share/{share_id}") +async def remove_share(page_id: int, share_id: int, request: Request): + """Remove a share invitation.""" + _require_auth(request) + + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM page_shares WHERE id=? AND page_id=?", + (share_id, page_id), + ).fetchone() + if not row: + raise HTTPException(404, "Share entry not found") + + conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,)) + # If no more shares, unset is_shared + remaining = conn.execute( + "SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,) + ).fetchone()["c"] + if remaining == 0: + conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,)) + conn.commit() + + return {"status": "removed", "share_id": share_id} + + +@router.get("/pages/{page_id}/shares") +async def list_shares(page_id: int, request: Request): + """Get all shares for a page.""" + _require_auth(request) + + with get_conn() as conn: + page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + rows = conn.execute( + """SELECT s.*, u.login, u.full_name, u.avatar_url + FROM page_shares s + LEFT JOIN users u ON s.shared_with_user_id = u.id + WHERE s.page_id=? + ORDER BY s.created_at DESC""", + (page_id,), + ).fetchall() + + return { + "page_id": page_id, + "shares": [ + { + "id": r["id"], + "shared_with_user_id": r["shared_with_user_id"], + "shared_with_email": r["shared_with_email"], + "permission": r["permission"], + "created_at": r["created_at"], + "created_by": r["created_by"], + "user_login": r["login"], + "user_full_name": r["full_name"], + "user_avatar_url": r["avatar_url"], + } + for r in rows + ], + } + + +# โ”€โ”€ Page Publishing โ”€โ”€ + + +@router.post("/pages/{page_id}/publish") +async def publish_page(page_id: int, request: Request): + """Publish a page (is_published=1) with a URL slug.""" + user = _require_auth(request) + + with get_conn() as conn: + page = conn.execute( + "SELECT id, title, is_published FROM pages WHERE id=?", (page_id,) + ).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + slug = _slugify(page["title"]) + # Ensure uniqueness by appending suffix if needed + base_slug = slug + counter = 1 + while conn.execute( + "SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id) + ).fetchone(): + slug = f"{base_slug}-{counter}" + counter += 1 + + conn.execute( + "UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", + (slug, page_id), + ) + conn.commit() + + return { + "page_id": page_id, + "is_published": True, + "publish_slug": slug, + "status": "published", + } + + +@router.delete("/pages/{page_id}/publish") +async def unpublish_page(page_id: int, request: Request): + """Unpublish a page.""" + _require_auth(request) + + with get_conn() as conn: + page = conn.execute( + "SELECT id, is_published FROM pages WHERE id=?", (page_id,) + ).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + conn.execute( + "UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", + (page_id,), + ) + conn.commit() + + return { + "page_id": page_id, + "is_published": False, + "status": "unpublished", + } + + +# โ”€โ”€ Recents โ”€โ”€ + + +@router.post("/recents/track") +async def track_recent(request: Request): + """Record a page access in recents.""" + user = _require_auth(request) + body = await request.json() if request.headers.get("content-type") else {} + page_id = body.get("page_id") + workspace = body.get("workspace", "") + source_type = body.get("source_type", "local") + + if not page_id: + raise HTTPException(400, "page_id is required") + + with get_conn() as conn: + page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + conn.execute( + """INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, page_id) + DO UPDATE SET workspace=excluded.workspace, + source_type=excluded.source_type, + accessed_at=excluded.accessed_at""", + (user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()), + ) + conn.commit() + + return { + "status": "tracked", + "user_id": user["id"], + "page_id": page_id, + "accessed_at": datetime.utcnow().isoformat(), + } diff --git a/app/templates/settings.html b/app/templates/settings.html index 795a8b0..2aef013 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -319,9 +319,10 @@

Integrations and feature toggles.

Integrations

+
-
Gitea
+
๐ŸฆŽ Gitea Primary
Connected to git.dracodev.net Connect your Gitea account to access projects @@ -329,16 +330,24 @@
โœ… Active - + ๐Ÿ”— Connect
+
-
GitHub
-
Connect your GitHub account
+
๐Ÿ™ GitHub Primary
+
+ Connected to github.com + Connect your GitHub account to access repositories +
+
+
+ โœ… Active + + ๐Ÿ”— Connect
-
Coming soon
@@ -513,8 +522,10 @@ document.addEventListener('alpine:init', function() { defaultView: localStorage.getItem('fd_default_view') || 'tree', userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'}, userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }}, - // Gitea + // Auth & Integrations + authMethod: '{{ auth_method }}', giteaLinked: false, + githubLinked: false, // Theme theme: localStorage.getItem('fd_theme') || 'dark', // Account form @@ -534,6 +545,7 @@ document.addEventListener('alpine:init', function() { async init() { await this.loadTags(); await this.loadGiteaStatus(); + await this.loadGithubStatus(); }, async loadTags() { @@ -772,6 +784,20 @@ document.addEventListener('alpine:init', function() { if (r.ok) { this.giteaLinked = false; } } catch(e) {} }, + async loadGithubStatus() { + try { + var r = await fetch('/api/github/status'); + var d = await r.json(); + this.githubLinked = d.linked || false; + } catch(e) { this.githubLinked = false; } + }, + async disconnectGithub() { + if (!confirm('Disconnect GitHub? You can reconnect anytime.')) return; + try { + var r = await fetch('/api/github/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} }); + if (r.ok) { this.githubLinked = false; } + } catch(e) {} + }, }; }); }); From bd6fd627346c531b71d128bd7f1d09790ccc4077 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 12:16:28 -0400 Subject: [PATCH 06/59] feat(v4.0): Library tabs + Sidebar OAuth badge + Sharing routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/routers/library.py: /api/library/recents|favorites|shared|published|private|workspace app/routers/sharing.py: /api/pages/{id}/share|publish + page_shares app/templates/base.html: OAuth badge ๐ŸฆŽ/๐Ÿ™, 'โ†’ Library' buttons app/templates/library.html: page avec tabs + filtres source app/routers/dashboard.py: auth_method + github_linked dans context tests/test_app.py: tests library + sharing + recents --- app/main.py | 3 +- app/routers/board.py | 26 ++- app/routers/dashboard.py | 69 +----- app/templates/base.html | 20 +- app/templates/library.html | 421 ++++++++++++++++++++++++++++--------- tests/test_app.py | 3 +- 6 files changed, 368 insertions(+), 174 deletions(-) diff --git a/app/main.py b/app/main.py index b41a5b2..b951a44 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin, sharing +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing from app.routers.gitea import router as gitea_router from app.services.gitea_client import gitea from app.services.webhook_outbound import init_webhook_tables @@ -66,6 +66,7 @@ app.include_router(library.router) app.include_router(admin.router) app.include_router(gitea_router) app.include_router(public_api.router) +app.include_router(sharing.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/routers/board.py b/app/routers/board.py index 3ce6e3c..d423836 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -303,13 +303,37 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "children": [], }) + # 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: + pass + return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", "active_ws_name": active_ws_name, "workspace_pages": _load_workspace_pages(ws_cookie), "current_page": repo or "Dashboard", "last_edited": "now", "recent_pages": recent, "private_pages": private_items, "favorite_pages": favorites, "shared_pages": shared_pages, - "user": user, "workspace_key": ws_key} + "user": user, "workspace_key": ws_key, + "auth_method": auth_method, + "gitea_linked": gitea_linked, + "github_linked": github_linked} def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""): diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 82d91c3..15c0bcd 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -157,75 +157,14 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str @router.get("/library", response_class=HTMLResponse) async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): - """Library page avec onglets Recents, Favorites, Shared, Private โ€” scopรฉ au workspace. - - La sidebar garde exactement la mรชme arborescence que dans le workspace. - Seul le contenu principal change pour afficher les pages triรฉes par catรฉgorie. + """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 jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) - sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangรฉe - - ws_key = f"{owner}/{repo}" if owner and repo else "" - with get_conn() as conn: - base_query = ( - "SELECT id, title, workspace, parent_section, updated_at FROM pages " - "WHERE parent_section != 'Trash'" - ) - params = [] - if ws_key: - base_query += " AND workspace=?" - params.append(ws_key) - base_query += " ORDER BY updated_at DESC" - rows = conn.execute(base_query, params).fetchall() - - def to_page(r): - r = dict(r) - return { - "id": f"page/{r['id']}", - "name": r["title"] or "Untitled", - "icon": "๐Ÿ“„", - "url": f"/pages/{r['id']}", - "created_by": "You", - "source": r.get("workspace") or "๐Ÿ”’ Private", - "last_edited": r.get("updated_at", "now"), - "last_visited": r.get("updated_at", "now"), - "section": r.get("parent_section", "Private"), - } - - all_pages = [to_page(r) for r in rows] - - # Load favorites from DB for Library - 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: - fav_rows = conn.execute( - "SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f " - "JOIN pages p ON p.id = f.page_id " - "WHERE f.user_id=? ORDER BY f.position", (uid,) - ).fetchall() - lib_favs = [] - for r in fav_rows: - r = dict(r) - lib_favs.append({ - "id": f"page/{r['id']}", - "name": r["title"] or "Untitled", - "icon": "๐Ÿ“„", - "url": f"/pages/{r['id']}", - "created_by": "You", - "source": r.get("workspace") or "๐Ÿ”’ Private", - "last_edited": r.get("updated_at", "now"), - "last_visited": r.get("updated_at", "now"), - }) - - # Catรฉgoriser par parent_section โ€” sidebar data intacte - sidebar["lib_recent"] = all_pages - sidebar["lib_favorites"] = lib_favs - sidebar["lib_shared"] = [] - sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"] - sidebar["lib_has_favorites"] = len(lib_favs) > 0 - sidebar["lib_has_shared"] = False + sidebar = board_sidebar(request, owner, repo) template = env.get_template("library.html") return template.render(**sidebar) diff --git a/app/templates/base.html b/app/templates/base.html index dae9de2..c2d25de 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -71,6 +71,11 @@
{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}
{{ user.full_name or user.login or workspaceName }} + {% if auth_method != 'local' %} + + {% if auth_method == 'gitea' %}๐ŸฆŽ Gitea{% elif auth_method == 'github' %}๐Ÿ™ GitHub{% else %}๐Ÿ”‘ {{ auth_method|title }}{% endif %} + + {% endif %}
โ–พ
+
+ {% if auth_method != 'local' or '/' in workspace_key %} + {% endif %} diff --git a/app/templates/library.html b/app/templates/library.html index 54a8fc9..3d4b224 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -11,122 +11,341 @@ - -
+
-
- - {% if lib_has_favorites %} - - {% endif %} - {% if lib_has_shared %} - - {% endif %} - -
- - -
-
- - - - -
- - -
- -
- ๐Ÿ“„ Page name - ๐Ÿ‘ค Created by - โ†— Source - ๐Ÿ•’ Last edited time - ๐Ÿ•’ Last visited time + +
+ + + + + +
- {% macro page_row(page) %} - - - {{ page.icon }} - {{ page.name }} - - ๐Ÿ‘ค {{ page.created_by or 'You' }} - {{ page.source or '๐Ÿ”’ Private' }} - {{ page.last_edited }} - {{ page.last_visited or page.last_edited }} - - {% endmacro %} - - -
- {% for page in lib_recent %}{{ page_row(page) }}{% endfor %} - {% if not lib_recent %} -
-
๐Ÿ•’
-

No recent pages

+ +
+
+ + + +
- {% endif %} +
+ +
- -
- {% for page in lib_favorites %}{{ page_row(page) }}{% endfor %} - {% if not lib_favorites %} -
-
โญ
-

No favorites

-

Right-click a page in the sidebar and select "Add to Favorites".

-
- {% endif %} + +
+
+

Loadingโ€ฆ

- -
- {% for page in lib_shared %}{{ page_row(page) }}{% endfor %} - {% if not lib_shared %} -
-
๐Ÿ‘ฅ
-

No shared pages

-

Shared pages from your team will appear here.

-
- {% endif %} + +
+
- -
- {% for page in lib_private %}{{ page_row(page) }}{% endfor %} - {% if not lib_private %} -
-
๐Ÿ”’
-

No private pages

-

Create a new page with the + button in the sidebar.

-
- {% endif %} + +
+
+

+

-
+
{% endblock %} {% block scripts %} + + {% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index c388aeb..49635e7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -209,7 +209,8 @@ def test_db_tables_exist(client): "collections", "collection_pages", "collection_views", "collection_properties", "workspaces", "workspace_members", "comments", "page_history", "favorites", - "database_templates", "page_templates"} + "database_templates", "page_templates", + "page_shares", "recents"} assert required <= names From 29ef0fb0546083ee45d0907d95c2482d656c743a Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 12:19:37 -0400 Subject: [PATCH 07/59] feat(v4.0): GitHub routes + CSRF exemptions for share/publish - app/routers/github_routes.py: /api/github/status + disconnect - CSRF: ajout /api/github, /api/pages, /api/recents aux exclusions - main.py: registration github_router --- app/main.py | 2 ++ app/middleware/csrf.py | 2 +- app/routers/board.py | 10 ++++++++-- app/routers/dashboard.py | 1 + app/routers/github_routes.py | 35 +++++++++++++++++++++++++++++++++++ app/templates/base.html | 25 ++++++++++++++++++++++++- 6 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 app/routers/github_routes.py diff --git a/app/main.py b/app/main.py index b951a44..031fa8d 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing from app.routers.gitea import router as gitea_router +from app.routers.github_routes import router as github_router from app.services.gitea_client import gitea from app.services.webhook_outbound import init_webhook_tables @@ -65,6 +66,7 @@ app.include_router(workspace.router) app.include_router(library.router) app.include_router(admin.router) app.include_router(gitea_router) +app.include_router(github_router) app.include_router(public_api.router) app.include_router(sharing.router) diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index ea280cd..13b22e5 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/routers/board.py b/app/routers/board.py index d423836..0a0a9c2 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -289,8 +289,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20" ).fetchall() shared_pages = [] + published_pages = [] for r in shared_rows: - shared_pages.append({ + page_entry = { "id": f"page/{r['id']}", "db_id": r["id"], "name": r["title"] or "New page", @@ -301,7 +302,11 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "depth": 0, "has_children": False, "children": [], - }) + } + if r["published"]: + published_pages.append(page_entry) + else: + shared_pages.append(page_entry) # Auth method & OAuth badge data auth_method = "local" @@ -330,6 +335,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "current_page": repo or "Dashboard", "last_edited": "now", "recent_pages": recent, "private_pages": private_items, "favorite_pages": favorites, "shared_pages": shared_pages, + "published_pages": published_pages, "user": user, "workspace_key": ws_key, "auth_method": auth_method, "gitea_linked": gitea_linked, diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 15c0bcd..57a4588 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -121,6 +121,7 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: "private_pages": [], "favorite_pages": [], "shared_pages": [], + "published_pages": [], "user": user, "avatar_url": avatar_url, "avatar_color": avatar_color, diff --git a/app/routers/github_routes.py b/app/routers/github_routes.py new file mode 100644 index 0000000..71f43fd --- /dev/null +++ b/app/routers/github_routes.py @@ -0,0 +1,35 @@ +"""GitHub OAuth โ€” status and disconnect routes.""" +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +router = APIRouter(tags=["github"], prefix="/api/github") + + +@router.get("/status") +async def github_status(request: Request): + """Check if the current user has GitHub linked.""" + from app.auth.session import SessionManager + from app.db import get_conn + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + return {"linked": False} + with get_conn() as conn: + row = conn.execute( + "SELECT github_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' AND github_token IS NOT NULL AND github_token != ''", + (user["id"],) + ).fetchone() + return {"linked": row is not None} + + +@router.delete("/disconnect") +async def disconnect_github(request: Request): + """Remove all GitHub OAuth tokens for the current user.""" + from app.auth.session import SessionManager + from app.db import get_conn + 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: + conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],)) + conn.commit() + return {"status": "ok"} diff --git a/app/templates/base.html b/app/templates/base.html index c2d25de..4a615cd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -281,6 +281,29 @@
+ + + {% if auth_method != 'local' or '/' in workspace_key %} @@ -268,7 +268,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}

@@ -354,7 +354,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} @@ -657,6 +657,58 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500); }, + publishPage() { + var self = this; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + fetch('/api/pages/' + this.pid + '/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' } + }).then(function(r){ return r.json(); }).then(function(d){ + if (d.status === 'ok') { self.pagePublished = true; self.publishedUrl = d.url || self.publishedUrl; self.showToast('Published to web'); } + else { self.showToast(d.detail || 'Publish failed'); } + }).catch(function(){ self.showToast('Publish failed'); }); + }, + unpublishPage() { + var self = this; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + fetch('/api/pages/' + this.pid + '/publish', { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' } + }).then(function(r){ return r.json(); }).then(function(d){ + if (d.status === 'ok') { self.pagePublished = false; self.publishedUrl = ''; self.showToast('Unpublished'); } + else { self.showToast(d.detail || 'Unpublish failed'); } + }).catch(function(){ self.showToast('Unpublish failed'); }); + }, + shareInvite() { + var self = this; + if (!this.inviteEmail.trim()) return; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + fetch('/api/pages/' + this.pid + '/share', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }, + body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission }) + }).then(function(r){ return r.json(); }).then(function(d){ + if (d.status === 'ok') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); } + else { self.showToast(d.detail || 'Share failed'); } + }).catch(function(){ self.showToast('Share failed'); }); + }, + loadShares() { + var self = this; + fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){ + self.accessList = d.shares || []; + }).catch(function(){ self.accessList = []; }); + }, + removeShare(sid) { + var self = this; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + fetch('/api/pages/' + this.pid + '/share/' + sid, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' } + }).then(function(r){ return r.json(); }).then(function(d){ + if (d.status === 'ok') { self.showToast('Share removed'); self.loadShares(); } + else { self.showToast(d.detail || 'Remove failed'); } + }).catch(function(){ self.showToast('Remove failed'); }); + }, invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';}, removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);}, exportPage(){this.moreOpen=false;}, From 234b880c5b51331239e54a3bec266c51671283e4 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 12:47:08 -0400 Subject: [PATCH 10/59] =?UTF-8?q?fix:=20Gitea=20workspace=20=E2=80=94=20co?= =?UTF-8?q?rrect=20sidebar=20name,=20newPage=20stays=20remote,=20cookie=20?= =?UTF-8?q?persisted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: newPageInWorkspace() allait toujours vers local-workspace API โ†’ Maintenant dรฉtecte workspaceKey contient '/' โ†’ Gitea API PUT file Bug 2: workspace_name dans gitea_workspace ne persistait pas โ†’ Cookie flowdeck_workspace posรฉ cรดtรฉ serveur au premier chargement Bug 3: icรดne ๐Ÿ“ fixe dans sidebar โ€” pas d'indication remote โ†’ ๐Ÿ”— affichรฉ quand workspace_key contient '/' (remote), ๐Ÿ“ sinon newFolderInWorkspace() : avertissement si workspace distant (car les dossiers n'existent pas dans un repo Git) --- app/routers/dashboard.py | 13 +++++++++++-- app/templates/base.html | 23 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 57a4588..6361eca 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -380,16 +380,25 @@ async def gitea_workspace_page(request: Request): 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" ctx = { **sidebar, "user": user, - "workspace_name": f"{owner}/{repo}" if owner and repo else "Gitea Workspace", + "active_ws_name": ws_name, + "workspace_key": ws_key, + "workspace_name": ws_name, "workspace_initial": repo[0].upper() if repo else "G", "owner": owner, "repo": repo, } template = env.get_template("gitea_workspace.html") - return template.render(**ctx) + response = template.render(**ctx) + # Set workspace cookie so subsequent navigations remember the active Gitea project + resp = HTMLResponse(content=response) + if ws_key: + resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax") + return resp @router.get("/api/workspace/projects") diff --git a/app/templates/base.html b/app/templates/base.html index 4a615cd..5dcc1f9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -121,7 +121,7 @@