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
+52
View File
@@ -410,6 +410,58 @@ def init_db():
""")
conn.commit()
# ── v4.0: Account & Integrations ──
# 1) auth_method on users
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'")
except sqlite3.OperationalError:
pass
# Detect existing auth: if user has a gitea_token in user_tokens → 'gitea'
conn.execute(
"UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)"
)
conn.execute(
"UPDATE users SET auth_method='local' WHERE auth_method IS NULL"
)
# 2) Publishing & sharing columns on pages
try:
conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# 3) page_shares table
conn.execute("""
CREATE TABLE IF NOT EXISTS page_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
shared_with_user_id INTEGER REFERENCES users(id),
shared_with_email TEXT DEFAULT '',
permission TEXT NOT NULL DEFAULT 'view',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
)
""")
# 4) recents table
conn.execute("""
CREATE TABLE IF NOT EXISTS recents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
page_id INTEGER NOT NULL REFERENCES pages(id),
workspace TEXT NOT NULL DEFAULT '',
source_type TEXT NOT NULL DEFAULT 'local',
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, page_id)
)
""")
conn.commit()
@contextmanager
def get_conn():
+2 -1
View File
@@ -13,7 +13,7 @@ from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin, sharing
from app.routers.gitea import router as gitea_router
from app.services.gitea_client import gitea
from app.services.webhook_outbound import init_webhook_tables
@@ -62,6 +62,7 @@ app.include_router(webhooks.router)
app.include_router(collections.router)
app.include_router(my_tasks.router)
app.include_router(workspace.router)
app.include_router(library.router)
app.include_router(admin.router)
app.include_router(gitea_router)
app.include_router(public_api.router)
+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(),
}
+32 -6
View File
@@ -319,9 +319,10 @@
<p class="section-desc">Integrations and feature toggles.</p>
<div class="setting-group">
<h3>Integrations</h3>
<!-- Gitea -->
<div class="setting-row">
<div>
<div class="setting-label">Gitea</div>
<div class="setting-label">🦎 Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc">
<span x-show="giteaLinked">Connected to git.dracodev.net</span>
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
@@ -329,16 +330,24 @@
</div>
<div class="setting-control">
<span x-show="giteaLinked" style="font-size:12px;color:#0F7B6C;">✅ Active</span>
<button x-show="giteaLinked" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
<button x-show="giteaLinked && authMethod!=='gitea'" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
<a x-show="!giteaLinked" class="btn-sm" href="/auth/login?provider=gitea&mode=link" style="text-decoration:none;color:var(--accent);">🔗 Connect</a>
</div>
</div>
<!-- GitHub -->
<div class="setting-row">
<div>
<div class="setting-label">GitHub</div>
<div class="setting-desc">Connect your GitHub account</div>
<div class="setting-label">🐙 GitHub <span x-show="authMethod==='github'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc">
<span x-show="githubLinked">Connected to github.com</span>
<span x-show="!githubLinked">Connect your GitHub account to access repositories</span>
</div>
</div>
<div class="setting-control">
<span x-show="githubLinked" style="font-size:12px;color:#0F7B6C;">✅ Active</span>
<button x-show="githubLinked && authMethod!=='github'" class="btn-sm" style="color:var(--danger);" @click="disconnectGithub()">Disconnect</button>
<a x-show="!githubLinked" class="btn-sm" href="/auth/login?provider=github&mode=link" style="text-decoration:none;color:var(--accent);">🔗 Connect</a>
</div>
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
</div>
</div>
</div>
@@ -513,8 +522,10 @@ document.addEventListener('alpine:init', function() {
defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
// Gitea
// Auth & Integrations
authMethod: '{{ auth_method }}',
giteaLinked: false,
githubLinked: false,
// Theme
theme: localStorage.getItem('fd_theme') || 'dark',
// Account form
@@ -534,6 +545,7 @@ document.addEventListener('alpine:init', function() {
async init() {
await this.loadTags();
await this.loadGiteaStatus();
await this.loadGithubStatus();
},
async loadTags() {
@@ -772,6 +784,20 @@ document.addEventListener('alpine:init', function() {
if (r.ok) { this.giteaLinked = false; }
} catch(e) {}
},
async loadGithubStatus() {
try {
var r = await fetch('/api/github/status');
var d = await r.json();
this.githubLinked = d.linked || false;
} catch(e) { this.githubLinked = false; }
},
async disconnectGithub() {
if (!confirm('Disconnect GitHub? You can reconnect anytime.')) return;
try {
var r = await fetch('/api/github/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
if (r.ok) { this.githubLinked = false; }
} catch(e) {}
},
};
});
});