"""FlowDeck β€” Dashboard: liste des projets Gitea + sidebar data.""" from __future__ import annotations import logging from fastapi import APIRouter, Request, Query from fastapi.responses import HTMLResponse, RedirectResponse from app.services.gitea_client import gitea from app.auth.session import SessionManager from app.db import get_conn logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) def _get_user_or_redirect(request: Request): """Return decoded user or a RedirectResponse to login page. Skips redirect when DB has no users (fresh install / test env).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: # Allow through if no users exist yet (fresh install / tests) try: with get_conn() as conn: count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if count == 0: return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} except Exception: pass return RedirectResponse("/auth/login?provider=local", status_code=302) return user def _sidebar_data(request: Request, repos: list[dict]) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) ws = user.get("login", "Bruno") if user else "Bruno" initial = ws[0].upper() if ws else "B" recent_pages = [] for repo in repos[:10]: full_name = repo.get("full_name", "") recent_pages.append({ "id": full_name, "name": repo.get("name", full_name), "icon": "πŸ“", "url": f"/board/{full_name}", "active": False, "indent": 0, "depth": 0, "has_children": False, "children": [], }) # Active workspace from cookie from app.routers.board import _load_workspace_pages ws_cookie = request.cookies.get("flowdeck_workspace", "") active_ws_name = "Workspace" workspace_pages = [] if ws_cookie: workspace_pages = _load_workspace_pages(ws_cookie) try: with get_conn() as conn: ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone() if ws_row: active_ws_name = ws_row["name"] except (ValueError, Exception): pass return { "workspace_name": ws, "workspace_initial": initial, "active_ws_name": active_ws_name, "workspace_pages": workspace_pages, "current_page": "Dashboard", "last_edited": "now", "recent_pages": recent_pages, "private_pages": [], "favorite_pages": [], "shared_pages": [], "user": user, "workspace_key": "", } @router.get("/trash", response_class=HTMLResponse) 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) with get_conn() as conn: ws_key = f"{owner}/{repo}" if owner and repo else "" # Pages are soft-deleted via parent_section='Trash' if ws_key: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", (ws_key,), ).fetchall() else: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", ).fetchall() sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] template = env.get_template("trash.html") return template.render(**sidebar) @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. """ 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 template = env.get_template("library.html") return template.render(**sidebar) @router.get("/pages/{page_id}", response_class=HTMLResponse) async def view_page_root(request: Request, page_id: int): """Render a Markdown page at root level with workspace context.""" 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: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: return HTMLResponse("

Page not found

", status_code=404) page = dict(row) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] owner, repo = parts[0], parts[1] if len(parts) > 1 else "" sidebar = board_sidebar(request, owner, repo) # Load sub-pages with get_conn() as conn: subs = conn.execute( "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", (page_id,), ).fetchall() ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], "page_favorited": False, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), "page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": False, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}} template = env.get_template("page_editor.html") response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) @router.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """Account management panel.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) with get_conn() as conn: users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall() ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} template = env.get_template("accounts.html") return template.render(**ctx) @router.get("/accounts/settings", response_class=HTMLResponse) async def settings_page(request: Request): """User settings page β€” profile, forges, tokens.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: from fastapi.responses import RedirectResponse return RedirectResponse("/auth/login?provider=local", status_code=302) # Check forge connections gitea_connected = False github_connected = False if user.get("id"): with get_conn() as conn: tokens = conn.execute( "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) ).fetchall() for t in tokens: if t["provider"] == "gitea": gitea_connected = True elif t["provider"] == "github": github_connected = True ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected} template = env.get_template("settings.html") response = template.render(**ctx) return HTMLResponse(content=response) # ═══════════ User API endpoints ═══════════ def _get_user_id(request: Request) -> int: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) return user["id"] if user and user.get("id") else 1 @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) with get_conn() as conn: conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) conn.commit() return {"status": "ok"} @router.put("/api/user/password") async def update_password(request: Request): import json from app.password_utils import hash_password body = await request.json() password = body.get("password", "").strip() if len(password) < 6: return {"error": "Password must be at least 6 characters"} uid = _get_user_id(request) with get_conn() as conn: conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) conn.commit() return {"status": "ok"} @router.post("/api/user/token") async def generate_token(request: Request): import secrets uid = _get_user_id(request) token = secrets.token_hex(32) with get_conn() as conn: conn.execute( "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", (uid, token), ) conn.commit() return {"token": f"fd_{token}"} @router.delete("/api/user/forge/{provider}") async def disconnect_forge(request: Request, provider: str): uid = _get_user_id(request) with get_conn() as conn: conn.execute( "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) ) conn.commit() return {"status": "ok"} @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, search: str = Query(default=""), show_archived: bool = Query(default=False), ): auth = _get_user_or_redirect(request) if isinstance(auth, RedirectResponse): return auth # Check if user has Gitea connected β€” if not, show local workspace user_id = auth.get("id") if isinstance(auth, dict) else 1 has_gitea = False try: with get_conn() as conn: tok = conn.execute( "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user_id,), ).fetchone() has_gitea = bool(tok) except Exception: pass if not has_gitea: return RedirectResponse("/local-workspace", status_code=302) try: repos = await gitea.get_user_repos(page=1, limit=50) if search: q = search.lower() repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()] if not show_archived: repos = [r for r in repos if not r.get("archived", False)] repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) except Exception as e: logger.error("Dashboard error: %s", e) repos = [] from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, repos) template = env.get_template("dashboard.html") return template.render(request=request, repos=repos, search=search, show_archived=show_archived, **sidebar) # ═══════════ Workspace ═══════════ @router.get("/workspace", response_class=HTMLResponse) async def workspace_page(request: Request): """Unified workspace showing all projects.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) ctx = {**sidebar, "user": user} template = env.get_template("workspace.html") return template.render(**ctx) @router.get("/api/workspace/projects") async def list_workspace_projects(request: Request): """List all projects: built-in + Gitea + GitHub.""" builtin = [] with get_conn() as conn: rows = conn.execute( "SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20" ).fetchall() for r in rows: count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0] builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"}) gitea_repos = [] try: repos = await gitea.get_user_repos(page=1, limit=50) for repo in repos: gitea_repos.append({ "id": str(repo.get("id", "")), "name": repo.get("name", ""), "full_name": repo.get("full_name", ""), "description": repo.get("description", ""), "html_url": repo.get("html_url", ""), "language": repo.get("language", ""), "forge": "gitea", }) except Exception: pass return {"builtin": builtin, "gitea": gitea_repos, "github": []} @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: return {"error": "Name required"} with get_conn() as conn: cursor = conn.execute( "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')", (name,), ) conn.commit() pid = cursor.lastrowid return {"id": pid, "name": name, "forge": "builtin"} # ═══════════ Workspace Members API ═══════════ @router.get("/api/workspace/{ws_id:int}/members") async def list_members(request: Request, ws_id: int): """List all members of a workspace.""" with get_conn() as conn: rows = conn.execute( """SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id = wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,) ).fetchall() return {"members": [dict(r) for r in rows]} @router.post("/api/workspace/{ws_id:int}/members") 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") if role not in ("owner", "admin", "editor", "viewer"): return {"error": "Invalid role"}, 400 with get_conn() as conn: user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone() if not user: return {"error": "User not found"}, 404 try: conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (ws_id, user["id"], role), ) conn.commit() except Exception: return {"error": "Already a member"}, 409 return {"status": "ok", "user_id": user["id"], "role": role} @router.put("/api/workspace/{ws_id:int}/members/{user_id:int}") 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"): return {"error": "Invalid role"} with get_conn() as conn: conn.execute( "UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, ws_id, user_id), ) conn.commit() return {"status": "ok"} @router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") async def remove_member(request: Request, ws_id: int, user_id: int): """Remove a member from a workspace.""" with get_conn() as conn: conn.execute( "DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id), ) conn.commit() return {"status": "ok"} # ═══════════ Local Workspace (file/folder CRUD) ═══════════ @router.get("/local-workspace", response_class=HTMLResponse) async def local_workspace_page(request: Request): """Local workspace page with file/folder tree.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) ws = _get_active_workspace(request) ws_id = ws["id"] if ws else None with get_conn() as conn: rows = conn.execute( "SELECT id, title FROM pages WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC", (ws_id,), ).fetchall() if ws_id else [] items = [{"id": r["id"], "name": r["title"] or "Untitled", "type": "page"} for r in rows] ctx = {**sidebar, "user": user, "workspace_name": sidebar.get("active_ws_name", "My Workspace"), "items": items} template = env.get_template("local_workspace.html") return template.render(**ctx) @router.get("/api/local-workspace/tree") async def local_workspace_tree(request: Request): """Return the file/folder tree filtered by active workspace.""" ws = _get_active_workspace(request) ws_id = ws["id"] if ws else None with get_conn() as conn: roots = conn.execute( "SELECT id, title, parent_section FROM pages WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC", (ws_id,), ).fetchall() if ws_id else [] tree = [] for r in roots: with get_conn() as conn: children = conn.execute( "SELECT id, title, parent_section FROM pages WHERE parent_id=? ORDER BY created_at", (r["id"],) ).fetchall() is_folder = r["parent_section"] == "Workspace" rtype = "folder" if is_folder else "page" item = {"id": r["id"], "name": r["title"] or "Untitled", "type": rtype, "children": [{"id": c["id"], "name": c["title"] or "Untitled", "type": "page"} for c in children]} tree.append(item) return {"tree": tree} @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") parent_id = body.get("parent_id") ws = _get_active_workspace(request) ws_id = ws["id"] if ws else None section = 'Workspace' if item_type == 'folder' else 'Private' with get_conn() as conn: cursor = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) " "VALUES ('', ?, ?, '', 'blocks', ?, ?)", (ws_id, name, section, parent_id), ) conn.commit() pid = cursor.lastrowid return {"id": pid, "name": name, "type": item_type} @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: conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id)) conn.commit() return {"status": "ok"} @router.delete("/api/local-workspace/items/{item_id:int}") async def delete_local_workspace_item(request: Request, item_id: int): """Delete a file (and its children).""" with get_conn() as conn: conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id)) conn.commit() return {"status": "ok"} @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: conn.execute( "UPDATE pages SET parent_id=? WHERE id=?", (new_parent_id, item_id), ) conn.commit() return {"status": "ok"} # ═══════════ Workspaces CRUD ═══════════ WORKSPACE_COOKIE = "flowdeck_workspace" def _get_active_workspace(request: Request) -> dict | None: """Get the active workspace ID from the cookie, or first workspace, or None.""" ws_id = request.cookies.get(WORKSPACE_COOKIE) if ws_id: try: with get_conn() as conn: ws = conn.execute("SELECT * FROM workspaces WHERE id=?", (int(ws_id),)).fetchone() if ws: return dict(ws) except (ValueError, Exception): pass # Fallback: first workspace with get_conn() as conn: ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone() if ws: return dict(ws) return None @router.get("/workspaces", response_class=HTMLResponse) async def workspaces_page(request: Request): """Workspaces list page.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) ctx = {**sidebar, "user": user} template = env.get_template("workspaces.html") return template.render(**ctx) @router.get("/api/workspaces") async def list_workspaces(request: Request): """List all workspaces for the current user.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 with get_conn() as conn: rows = conn.execute( "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count " "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC", (uid,), ).fetchall() workspaces = [] for r in rows: d = dict(r) workspaces.append(d) active = _get_active_workspace(request) return {"workspaces": workspaces, "active_id": active["id"] if active else None} @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() 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: cursor = conn.execute( "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, uid), ) conn.commit() ws_id = cursor.lastrowid # Add owner as member conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (ws_id, uid), ) conn.commit() return {"id": ws_id, "name": name} @router.put("/api/workspaces/{ws_id:int}") 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: return {"error": "Name required"} with get_conn() as conn: conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id)) conn.commit() return {"status": "ok"} @router.delete("/api/workspaces/{ws_id:int}") async def delete_workspace(request: Request, ws_id: int): """Delete a workspace and all its pages.""" with get_conn() as conn: conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) conn.commit() return {"status": "ok"} @router.post("/api/workspaces/{ws_id:int}/select") async def select_workspace(request: Request, ws_id: int): """Set the active workspace via cookie.""" from fastapi.responses import JSONResponse response = JSONResponse({"status": "ok", "workspace_id": ws_id}) response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/") return response