feat(v4.0): Settings/Integrations — Gitea + GitHub connect/disconnect matrix
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- GitHub integration row ajoutée (🦎 Gitea + 🐙 GitHub)
- Badge 'Primary' sur le provider principal (auth_method)
- Disconnect masqué pour le provider principal
- authMethod, githubLinked, giteaLinked dans Alpine data
- loadGithubStatus() + disconnectGithub() methods
- Matrice respectée: local→déco OK, gitea→déco github OK, github→déco gitea OK
This commit is contained in:
2026-07-14 12:14:41 -04:00
parent 8eb7049460
commit 802d681212
6 changed files with 604 additions and 7 deletions
+24
View File
@@ -90,6 +90,27 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
except (ValueError, Exception):
pass
# Auth method & OAuth badge data
auth_method = "local"
gitea_linked = False
github_linked = False
if user and user.get("id"):
try:
with get_conn() as conn:
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
if am_row and am_row["auth_method"]:
auth_method = am_row["auth_method"]
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_linked = True
elif t["provider"] == "github":
github_linked = True
except Exception:
pass
return {
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
@@ -104,6 +125,9 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"avatar_url": avatar_url,
"avatar_color": avatar_color,
"workspace_key": "",
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
}
+249
View File
@@ -0,0 +1,249 @@
"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
SOURCE_TYPES = {"all", "local", "gitea", "github"}
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
def _detect_source_type(workspace: str) -> str:
"""Detect source type from workspace field.
- '' or plain name (no slash) → local
- 'owner/repo' format → gitea
- 'github:owner/repo' format → github
"""
ws = (workspace or "").strip()
if not ws:
return "local"
if ws.startswith("github:"):
return "github"
if "/" in ws:
return "gitea"
return "local"
def _source_badge(source_type: str) -> str:
return {"local": "Local", "gitea": "Gitea", "github": "GitHub"}.get(source_type, "Local")
def _build_item(db_row: dict) -> dict:
"""Convert a DB row into a library item dict."""
workspace = db_row.get("workspace", "") or ""
source_type = _detect_source_type(workspace)
# Extract workspace display name
if source_type == "gitea":
workspace_name = workspace # "owner/repo"
elif source_type == "github":
workspace_name = workspace.replace("github:", "", 1)
else:
workspace_name = workspace or "Private"
# URL construction
page_id = db_row["id"]
if source_type == "gitea":
parts = workspace.split("/", 1)
owner = parts[0] if len(parts) > 0 else ""
repo = parts[1] if len(parts) > 1 else ""
url = f"/gitea-workspace?owner={owner}&repo={repo}"
elif source_type == "github":
gh_ws = workspace.replace("github:", "", 1)
parts = gh_ws.split("/", 1)
owner = parts[0] if len(parts) > 0 else ""
repo = parts[1] if len(parts) > 1 else ""
url = f"/gitea-workspace?owner={owner}&repo={repo}"
else:
url = f"/pages/{page_id}"
return {
"id": page_id,
"title": db_row.get("title") or "Untitled",
"icon": "📄",
"source_type": source_type,
"source_badge": _source_badge(source_type),
"workspace_name": workspace_name,
"updated_at": db_row.get("updated_at", ""),
"url": url,
}
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
"""Add WHERE clause for source_type filter. Mutates params in place."""
if source_type == "local":
query += " AND (workspace = '' OR workspace NOT LIKE '%/%' AND workspace NOT LIKE 'github:%')"
elif source_type == "gitea":
query += " AND workspace LIKE '%/%' AND workspace NOT LIKE 'github:%'"
elif source_type == "github":
query += " AND workspace LIKE 'github:%'"
# 'all' → no filter
return query, params
# ── Tab endpoints ──
@router.get("/recents")
async def library_recents(
request: Request,
source_type: str = Query(default="all"),
):
"""Recent pages ordered by updated_at DESC."""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/favorites")
async def library_favorites(
request: Request,
source_type: str = Query(default="all"),
):
"""Favorite pages for the current user."""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
"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 = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY f.position"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/shared")
async def library_shared(
request: Request,
source_type: str = Query(default="all"),
):
"""Pages shared with others (share_mode != 'private')."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE share_mode != 'private' AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/published")
async def library_published(
request: Request,
source_type: str = Query(default="all"),
):
"""Published pages (published = 1)."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE published = 1 AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/private")
async def library_private(
request: Request,
source_type: str = Query(default="all"),
):
"""Private pages (parent_section = 'Private')."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE parent_section = 'Private' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
@router.get("/workspace")
async def library_workspace(
request: Request,
source_type: str = Query(default="all"),
):
"""Pages from workspaces (non-empty workspace field)."""
if source_type not in SOURCE_TYPES:
source_type = "all"
query = (
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE workspace != '' AND parent_section != 'Trash' AND deleted_at IS NULL"
)
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = [_build_item(dict(r)) for r in rows]
return {"items": items}
+245
View File
@@ -0,0 +1,245 @@
"""FlowDeck — Sharing, Publishing & Recents API (v4.0)."""
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
def _require_auth(request: Request) -> dict:
"""Require an authenticated session. Returns user dict or raises 401."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a page title."""
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
# ── Page Sharing ──
@router.post("/pages/{page_id}/share")
async def share_page(page_id: int, request: Request):
"""Invite a user or email to a page."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
target_user_id = body.get("user_id")
email = body.get("email", "")
permission = body.get("permission", "view")
if permission not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
if not target_user_id and not email:
raise HTTPException(400, "Provide user_id or email to share with.")
with get_conn() as conn:
# Verify page exists
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
# Verify target user exists if user_id given
if target_user_id:
target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone()
if not target:
raise HTTPException(404, "Target user not found")
cur = conn.execute(
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
VALUES (?, ?, ?, ?, ?)""",
(page_id, target_user_id, email, permission, user["id"]),
)
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
conn.commit()
return {
"id": cur.lastrowid,
"page_id": page_id,
"shared_with_user_id": target_user_id,
"shared_with_email": email,
"permission": permission,
"status": "shared",
}
@router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation."""
_require_auth(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
(share_id, page_id),
).fetchone()
if not row:
raise HTTPException(404, "Share entry not found")
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
# If no more shares, unset is_shared
remaining = conn.execute(
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
).fetchone()["c"]
if remaining == 0:
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,))
conn.commit()
return {"status": "removed", "share_id": share_id}
@router.get("/pages/{page_id}/shares")
async def list_shares(page_id: int, request: Request):
"""Get all shares for a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT s.*, u.login, u.full_name, u.avatar_url
FROM page_shares s
LEFT JOIN users u ON s.shared_with_user_id = u.id
WHERE s.page_id=?
ORDER BY s.created_at DESC""",
(page_id,),
).fetchall()
return {
"page_id": page_id,
"shares": [
{
"id": r["id"],
"shared_with_user_id": r["shared_with_user_id"],
"shared_with_email": r["shared_with_email"],
"permission": r["permission"],
"created_at": r["created_at"],
"created_by": r["created_by"],
"user_login": r["login"],
"user_full_name": r["full_name"],
"user_avatar_url": r["avatar_url"],
}
for r in rows
],
}
# ── Page Publishing ──
@router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
user = _require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = _slugify(page["title"])
# Ensure uniqueness by appending suffix if needed
base_slug = slug
counter = 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base_slug}-{counter}"
counter += 1
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
(slug, page_id),
)
conn.commit()
return {
"page_id": page_id,
"is_published": True,
"publish_slug": slug,
"status": "published",
}
@router.delete("/pages/{page_id}/publish")
async def unpublish_page(page_id: int, request: Request):
"""Unpublish a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
return {
"page_id": page_id,
"is_published": False,
"status": "unpublished",
}
# ── Recents ──
@router.post("/recents/track")
async def track_recent(request: Request):
"""Record a page access in recents."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
page_id = body.get("page_id")
workspace = body.get("workspace", "")
source_type = body.get("source_type", "local")
if not page_id:
raise HTTPException(400, "page_id is required")
with get_conn() as conn:
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, page_id)
DO UPDATE SET workspace=excluded.workspace,
source_type=excluded.source_type,
accessed_at=excluded.accessed_at""",
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
)
conn.commit()
return {
"status": "tracked",
"user_id": user["id"],
"page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(),
}