v5.2.0: Infrastructure & Polish
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped

- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
This commit is contained in:
2026-09-10 23:47:35 -04:00
co-authored by Bruno
parent e9b6244ef0
commit 5c350ff8f6
77 changed files with 2447 additions and 293 deletions
+31 -40
View File
@@ -3,12 +3,12 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.services.gitea_client import gitea, get_user_gitea_client
from app.auth.session import SessionManager
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"])
@@ -253,6 +253,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
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)
@@ -283,6 +284,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
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)
@@ -311,6 +313,7 @@ async def view_page_root(request: Request, page_id: int):
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
@@ -428,7 +431,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
"""Comprehensive help & documentation page."""
from jinja2 import Environment, FileSystemLoader, BaseLoader
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
@@ -612,7 +615,6 @@ def _get_user_id(request: Request) -> int:
@router.put("/api/user/profile")
async def update_profile(request: Request):
import json
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
@@ -624,7 +626,6 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
import json
from app.password_utils import hash_password
body = await request.json()
password = body.get("password", "").strip()
@@ -710,7 +711,8 @@ async def dashboard(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
).fetchone()[0]
if ws_count == 0:
return RedirectResponse("/workspaces", status_code=302)
# v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302)
except Exception:
pass
return RedirectResponse("/local-workspace", status_code=302)
@@ -760,6 +762,7 @@ async def workspace_page(request: Request):
async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
@@ -816,7 +819,7 @@ 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(
@@ -850,7 +853,6 @@ async def list_workspace_projects(request: Request):
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -882,7 +884,6 @@ async def list_members(request: Request, ws_id: int):
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
"""Invite a user to a workspace by email."""
import json
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
@@ -906,7 +907,6 @@ async def invite_member(request: Request, ws_id: int):
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
"""Change a member's role."""
import json
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
@@ -1048,14 +1048,14 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"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 = {}
@@ -1070,12 +1070,12 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id)
# Compute size
size = 0
if r["content_format"] == "file":
@@ -1087,7 +1087,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
@@ -1238,7 +1238,6 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
item_type = body.get("type", "page")
@@ -1262,7 +1261,6 @@ async def create_local_workspace_item(request: Request):
@router.put("/api/local-workspace/items/{item_id:int}")
async def rename_local_workspace_item(request: Request, item_id: int):
"""Rename a file."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
@@ -1299,8 +1297,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
from pathlib import Path
import mimetypes
from pathlib import Path
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
@@ -1318,7 +1316,6 @@ async def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
"""Move an item to a new parent (drag & drop)."""
import json
body = await request.json()
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
@@ -1583,7 +1580,6 @@ async def list_workspaces(request: Request):
@router.post("/api/workspaces")
async def create_workspace(request: Request):
"""Create a new workspace."""
import json
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
@@ -1616,7 +1612,6 @@ async def create_workspace(request: Request):
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
"""Rename a workspace."""
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -1669,9 +1664,9 @@ async def app_settings_page(request: Request):
@router.post("/api/settings/avatar")
async def upload_avatar(request: Request):
"""Upload a user avatar image."""
from fastapi import UploadFile, File
import os
import uuid
from pathlib import Path
import uuid, os
form = await request.form()
file = form.get("file")
if not file:
@@ -1698,8 +1693,9 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from fastapi.responses import FileResponse
from pathlib import Path
from fastapi.responses import FileResponse
filepath = Path("/data/avatars") / filename
if not filepath.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
@@ -1735,7 +1731,6 @@ async def set_avatar_color(request: Request):
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
"""Create a tag for the current user."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
@@ -1756,7 +1751,6 @@ async def create_tag_global(request: Request):
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
"""Update a tag (name or color) — only if owned by user."""
import json
body = await request.json()
uid = _get_user_id(request)
with get_conn() as conn:
@@ -1831,7 +1825,6 @@ async def get_item_tags(item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
"""Add a tag to an item (creates tag if new, scoped to user)."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
@@ -1884,7 +1877,6 @@ async def search_by_tags(request: Request, tags: str = ""):
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
import json
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
@@ -1921,7 +1913,6 @@ async def search_by_tags(request: Request, tags: str = ""):
@router.put("/api/settings/account")
async def update_account(request: Request):
"""Update current user's profile: full_name, login, email, password."""
import json
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1951,8 +1942,9 @@ async def update_account(request: Request):
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
user_data = dict(row)
# Refresh session cookie with updated data
new_session = SessionManager.create_session(user_data)
# 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
@@ -1968,20 +1960,21 @@ async def sidebar_workspace_tree(request: Request):
in the main content area to keep the sidebar in sync.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _load_workspace_pages
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('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
@@ -1995,14 +1988,14 @@ async def sidebar_workspace_tree(request: Request):
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.from_string(
@@ -2079,7 +2072,6 @@ def _sanitize_id(block_id: str) -> str:
def _render_blocks_public(blocks: list) -> str:
"""Render FlowDeck blocks as plain HTML for public pages."""
import json as _json
html_parts = []
for b in blocks:
t = b.get("type", "paragraph")
@@ -2207,7 +2199,6 @@ async def api_page_content(page_id: int):
@router.put("/api/pages/{page_id:int}/rename")
async def api_rename_page(page_id: int, request: Request):
"""Inline rename a page title."""
import json as _json
body = await request.json()
title = (body.get("title") or "").strip()
if not title: