Files
flowdeck/app/routers/dashboard.py
T
bruno 9542353b55
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
fix: Home redirects local accounts to /local-workspace + sidebar Workspace section
- Dashboard '/' checks user_oauth_tokens for Gitea connection
- Local accounts (no Gitea token) redirect to /local-workspace
- Sidebar: Workspace section above Meetings with 'My Workspace' link
- Local users no longer see Gitea projects on Home page
2026-07-10 16:31:23 -04:00

550 lines
21 KiB
Python

"""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": [],
})
return {
"workspace_name": ws, "workspace_initial": initial,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": [],
"favorite_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("<h2>Page not found</h2>", 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)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY created_at DESC"
).fetchall()
items = [{"id": r["id"], "name": r["title"] or "Untitled", "type": "page"} for r in rows]
ctx = {**sidebar, "user": user, "workspace_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."""
with get_conn() as conn:
roots = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY created_at DESC"
).fetchall()
tree = []
for r in roots:
with get_conn() as conn:
children = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY created_at", (r["id"],)
).fetchall()
item = {"id": r["id"], "name": r["title"] or "Untitled", "type": "page",
"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."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
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, "type": "page"}
@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"}