feat(v4.0): Settings/Integrations — Gitea + GitHub connect/disconnect matrix
- 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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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(),
|
||||
}
|
||||
Reference in New Issue
Block a user