"""FlowDeck — Dashboard : settings. Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé. """ from __future__ import annotations import logging from fastapi import APIRouter, Body, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.auth.session import SessionManager from app.db import get_conn from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) # ═══════════ Settings Page ═══════════ @router.get("/settings", response_class=HTMLResponse) def app_settings_page(request: Request): """Settings & configuration page.""" from app.templating import ENV env = ENV 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} # Pass active workspace for breadcrumb nav menu ws = _get_active_workspace(request, user_id=_get_user_id(request)) ctx["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("settings.html") return template.render(**ctx) @router.post("/api/settings/avatar") async def upload_avatar(request: Request): """Upload a user avatar image.""" import os import uuid from pathlib import Path form = await request.form() file = form.get("file") if not file: return {"error": "No file"}, 400 user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return {"error": "Not authenticated"}, 401 # Save to data/avatars avatars_dir = Path("/data/avatars") avatars_dir.mkdir(parents=True, exist_ok=True) ext = os.path.splitext(file.filename)[1] or ".png" filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}" filepath = avatars_dir / filename content = await file.read() filepath.write_bytes(content) # Update user avatar_url avatar_url = f"/api/settings/avatar/{filename}" with get_conn() as conn: conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"])) conn.commit() return {"avatar_url": avatar_url} @router.get("/api/settings/avatar/{filename:path}") def serve_avatar_file(filename: str): """Serve an uploaded avatar image file.""" from pathlib import Path from fastapi.responses import FileResponse # A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette # accepte les `/`, donc `..%2f` ressortirait du dossier avatars. base_dir = Path("/data/avatars").resolve() filepath = (base_dir / filename).resolve() try: filepath.relative_to(base_dir) except ValueError: return JSONResponse({"error": "Path traversal denied"}, status_code=403) if not filepath.is_file(): return JSONResponse({"error": "Not found"}, status_code=404) return FileResponse(filepath) @router.get("/api/avatar/{user_id:int}") def get_avatar(user_id: int): """Redirect to the user's avatar.""" with get_conn() as conn: row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() if row and row["avatar_url"]: return RedirectResponse(row["avatar_url"], status_code=302) return JSONResponse({"error": "No avatar"}, status_code=404) @router.post("/api/settings/avatar-color") def set_avatar_color(request: Request, body: dict = Body(default={})): """Set the user's avatar background color.""" color = body.get("color", "#3A3A3A") user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return {"error": "Not authenticated"}, 401 with get_conn() as conn: conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"])) conn.commit() return {"status": "ok", "color": color} # ═══════════ Tag Management API (per-user) ═══════════ # ═══════════ Tag Management API (per-user) ═══════════ @router.post("/api/settings/tags") def create_tag_global(request: Request, body: dict = Body(default={})): """Create a tag for the current user.""" tag_name = body.get("name", "").strip().lower() color = body.get("color", "#787774") uid = _get_user_id(request) if not tag_name or not uid: return {"error": "Tag name required"}, 400 with get_conn() as conn: tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() if tag: conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"])) conn.commit() return {"tag": {"id": tag["id"], "name": tag_name, "color": color}} cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid)) conn.commit() return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}} @router.put("/api/settings/tags/{tag_id:int}") def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})): """Update a tag (name or color) — only if owned by user.""" uid = _get_user_id(request) with get_conn() as conn: if "name" in body: conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid)) if "color" in body: conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid)) conn.commit() return {"status": "ok"} @router.delete("/api/settings/tags/{tag_id:int}") def delete_tag_global(tag_id: int, request: Request): """Delete a tag — only if owned by user.""" uid = _get_user_id(request) with get_conn() as conn: conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,)) conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid)) conn.commit() return {"status": "ok"} @router.get("/api/settings/tags/all") def list_all_tags_global(request: Request): """List current user's tags with counts.""" uid = _get_user_id(request) with get_conn() as conn: rows = conn.execute( "SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count " "FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id " "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", (uid,), ).fetchall() return JSONResponse( {"tags": [dict(r) for r in rows]}, headers={"Cache-Control": "no-store"}, ) # ═══════════ Workspace Tags API ═══════════ # ═══════════ Workspace Tags API ═══════════ @router.get("/api/local-workspace/tags") def list_tags(request: Request): """List ALL user tags with counts scoped to the active workspace.""" ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None uid = _get_user_id(request) if not ws_id: return {"tags": []} with get_conn() as conn: rows = conn.execute( "SELECT t.id, t.name, t.color, " "(SELECT COUNT(*) FROM page_tags pt " " JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " " WHERE pt.tag_id = t.id) as count " "FROM tags t WHERE t.user_id = ? ORDER BY t.name", (ws_id, uid), ).fetchall() return JSONResponse( {"tags": [dict(r) for r in rows]}, headers={"Cache-Control": "no-store"}, ) @router.get("/api/local-workspace/items/{item_id:int}/tags") def get_item_tags(item_id: int): """Get tags for a specific item.""" with get_conn() as conn: rows = conn.execute( "SELECT t.id, t.name, t.color FROM tags t " "JOIN page_tags pt ON pt.tag_id = t.id " "WHERE pt.page_id = ? ORDER BY t.name", (item_id,), ).fetchall() return {"tags": [dict(r) for r in rows]} @router.post("/api/local-workspace/items/{item_id:int}/tags") def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})): """Add a tag to an item (creates tag if new, scoped to user).""" tag_name = body.get("name", "").strip().lower() tag_color = body.get("color", "#787774") uid = _get_user_id(request) if not tag_name: return {"error": "Tag name required"}, 400 with get_conn() as conn: # Get or create tag (per user) tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() if not tag: cursor = conn.execute( "INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid) ) conn.commit() tag_id = cursor.lastrowid tag = {"id": tag_id, "name": tag_name, "color": tag_color} else: tag_id = tag["id"] # Link tag to page (ignore duplicate) try: conn.execute( "INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (item_id, tag_id), ) conn.commit() except Exception: logger.exception("add_item_tag") return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} @router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") def remove_item_tag(item_id: int, tag_id: int): """Remove a tag from an item.""" with get_conn() as conn: conn.execute( "DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?", (item_id, tag_id), ) conn.commit() return {"status": "ok"} @router.get("/api/local-workspace/tags/search") def search_by_tags(request: Request, tags: str = ""): """Search items by tags (comma-separated).""" ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None if not ws_id: return {"items": []} tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()] if not tag_names: return {"items": []} with get_conn() as conn: placeholders = ",".join("?" for _ in tag_names) rows = conn.execute( f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, " f"p.content, p.created_at, p.updated_at " f"FROM pages p " f"JOIN page_tags pt ON pt.page_id = p.id " f"JOIN tags t ON t.id = pt.tag_id " f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL " f"ORDER BY p.updated_at DESC", tag_names + [ws_id], ).fetchall() items = [] for r in rows: is_folder = r["parent_section"] == "Workspace" size = len(r["content"] or "") items.append({ "id": r["id"], "name": r["title"] or "Untitled", "is_folder": is_folder, "content_format": r["content_format"], "created_at": r["created_at"], "updated_at": r["updated_at"], "size": size, "size_display": _format_size(size), }) return {"items": items} # ── Account update ── # ── Account update ── @router.put("/api/settings/account") def update_account(request: Request, body: dict = Body(default={})): """Update current user's profile: full_name, login, email, password.""" from app.password_utils import hash_password 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: uid = user["id"] if "full_name" in body: conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid)) if "login" in body: new_login = body["login"].strip() if new_login and new_login != user.get("login"): existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone() if existing: return JSONResponse({"error": "Username already taken"}, status_code=409) conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid)) if "email" in body: conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid)) if "password" in body and body["password"].strip(): pw = body["password"].strip() if len(pw) < 6: return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid)) conn.commit() row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() user_data = dict(row) # 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 # ═══════════ Sidebar Refresh API ═══════════ # ═══════════ Sidebar Refresh API ═══════════ @router.get("/api/sidebar/workspace-tree") def sidebar_workspace_tree(request: Request): """Return the sidebar workspace tree as HTML fragment. Called by appState().refreshSidebarTree() after CRUD operations in the main content area to keep the sidebar in sync. """ from app.routers.board import _load_workspace_pages from app.templating import ENV 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('