v5.2.0: Infrastructure & Polish
- 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:
+31
-40
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user