diff --git a/CHANGELOG.md b/CHANGELOG.md index e279ac3..818b6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,32 +1,45 @@ # Changelog -## [0.2.0] — 2026-07-08 — "FlowDeck" +## [0.3.0] — 2026-07-08 — "Auth + Card Detail + Webhooks" -### Added -- Renamed from Kanbante to FlowDeck -- Dashboard listant tous les projets Gitea (user + orgs) -- Board Kanban 5 colonnes : Backlog, À faire, En cours, Révision, Terminé -- Issues Gitea → cartes avec filtres (milestone, label, assignee) -- Drag & drop (SortableJS) avec persistance SQLite -- Sync bidirectionnelle : colonne → label Gitea, Terminé → close issue -- Colonnes personnalisables + mapping colonne→label -- WIP limits configurables -- Notes Markdown par projet -- API REST v1 : /api/health, /api/stats, /api/projects, /api/move, /api/col-mapping -- Thème clair/sombre avec persistance localStorage -- Cache Gitea in-memory avec TTL -- Docker + docker-compose -- Health check enrichi (DB + Gitea) -- Filtres intra-board (milestone, label, assignee) -- Pull requests affichées séparément -- Priorités (Low/Medium/High/Urgent) + dates d'échéance -- Architecture documentée (ARCHITECTURE.md) -- Roadmap (ROADMAP.md) +### Added — Auth & Multi-user (v0.3.0) +- OAuth2 Gitea (login via Gitea) — /auth/login, /auth/callback, /auth/logout +- Token par utilisateur stocké en BDD (user_tokens table) +- Session signed cookies (itsdangerous) — 7 jours +- CSRF protection middleware sur tous les POST/PUT/PATCH/DELETE +- Rate limiting in-memory (60 req/min par IP, configurable) +- Fallback mode sans OAuth: connexion automatique en tant qu'admin + +### Added — Issue Creation & Editing (v0.5.0 partial) +- Création d'issues depuis FlowDeck (POST /api/issues/{owner}/{repo}) +- Formulaire inline avec labels, milestone, assignee +- Édition de titre inline (PATCH /api/issues/{owner}/{repo}/{issue_id}) +- Card metadata: priority + due date (POST /api/card/{owner}/{repo}/{issue_id}) + +### Added — Webhooks (v0.4.0) +- Endpoint webhook receiver: POST /api/webhook +- Signature HMAC-SHA256 verification +- Gère: issue opened/closed/labeled/deleted, PR, repository +- Auto-registration: POST /api/webhook/register/{owner}/{repo} +- Webhook status: GET /api/webhook/status/{owner}/{repo} +- GiteaClient: list_webhooks, create_webhook, delete_webhook + +### Added — Card Detail + Checklists (v0.5.0) +- Modal détail carte (double-clic): description, labels, assignee, milestone, commentaires +- Checklists avec items checkable + ajout/suppression inline +- Priorités (Low/Medium/High/Urgent) + dates d'échéance éditables +- Commentaires Gitea affichés dans le modal +- GET /api/issues/{owner}/{repo}/{issue_id} — détail complet +- GET /api/collaborators/{owner}/{repo} — pour assignee picker +- Bouton "🔔 Webhook" pour enregistrer le webhook Gitea ### Stack -- FastAPI + Uvicorn + httpx + Jinja2 + HTMX + Alpine.js + SortableJS -- SQLite WAL mode -- Python 3.12-slim Docker +- + itsdangerous (signed sessions) +- + slowapi (rate limiting, optional) +- + starlette SessionMiddleware pour OAuth2 state + +## [0.2.0] — 2026-07-08 — "FlowDeck" +- Dashboard + Board Kanban + Gitea sync + Notes + Docker ## [0.1.0] — Kanbante (non publié) - MVP initial diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..f46c919 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,3 @@ +"""FlowDeck — Auth module: session, OAuth2, dependencies.""" +from app.auth.oauth import GiteaOAuth +from app.auth.session import SessionManager, get_current_user diff --git a/app/auth/oauth.py b/app/auth/oauth.py new file mode 100644 index 0000000..1c1475a --- /dev/null +++ b/app/auth/oauth.py @@ -0,0 +1,77 @@ +"""FlowDeck — Gitea OAuth2 client.""" +from __future__ import annotations + +import logging +from urllib.parse import urlencode + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class GiteaOAuth: + """Gitea OAuth2 client for user authentication.""" + + AUTHORIZE_URL = f"{settings.gitea_url}/login/oauth/authorize" + TOKEN_URL = f"{settings.gitea_url}/login/oauth/access_token" + USER_URL = f"{settings.gitea_url}/api/v1/user" + + def __init__(self): + self.client_id = settings.gitea_oauth_client_id + self.client_secret = settings.gitea_oauth_client_secret + self.redirect_uri = settings.oauth_redirect_uri + + @property + def enabled(self) -> bool: + return bool(self.client_id and self.client_secret) + + def get_authorize_url(self, state: str) -> str: + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", + "state": state, + } + return f"{self.AUTHORIZE_URL}?{urlencode(params)}" + + async def exchange_code(self, code: str) -> dict | None: + """Exchange authorization code for access token.""" + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + self.TOKEN_URL, + data={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri, + }, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + data = resp.json() + return data + except Exception as e: + logger.error("OAuth token exchange failed: %s", e) + return None + + async def get_user(self, access_token: str) -> dict | None: + """Get user info from Gitea API.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + self.USER_URL, + headers={"Authorization": f"token {access_token}"}, + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.error("OAuth user fetch failed: %s", e) + return None + + +# Singleton +gitea_oauth = GiteaOAuth() diff --git a/app/auth/session.py b/app/auth/session.py new file mode 100644 index 0000000..858e040 --- /dev/null +++ b/app/auth/session.py @@ -0,0 +1,68 @@ +"""FlowDeck — Session management with signed cookies.""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +from app.config import settings + +_serializer = URLSafeTimedSerializer(settings.app_secret_key) + + +class SessionManager: + """Manages user sessions via signed cookies.""" + + @staticmethod + def create_session(user_data: dict) -> str: + """Create a signed session cookie value.""" + payload = { + "user": user_data, + "created_at": datetime.utcnow().isoformat(), + } + return _serializer.dumps(payload) + + @staticmethod + def decode_session(cookie: str) -> dict | None: + """Decode and validate a session cookie. Returns user data or None.""" + try: + payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days + return payload.get("user") + except (BadSignature, SignatureExpired): + return None + + @staticmethod + def store_token(user_id: int, gitea_token: str) -> None: + """Store a user's Gitea OAuth token in SQLite.""" + from app.db import get_conn + with get_conn() as conn: + conn.execute( + """INSERT INTO user_tokens (gitea_user_id, gitea_token, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(gitea_user_id) + DO UPDATE SET gitea_token=excluded.gitea_token, updated_at=CURRENT_TIMESTAMP""", + (user_id, gitea_token), + ) + conn.commit() + + @staticmethod + def get_token(user_id: int) -> str | None: + """Get a user's stored Gitea token.""" + from app.db import get_conn + with get_conn() as conn: + row = conn.execute( + "SELECT gitea_token FROM user_tokens WHERE gitea_user_id=?", + (user_id,), + ).fetchone() + return row["gitea_token"] if row else None + + +# FastAPI dependency +async def get_current_user(request) -> dict | None: + """FastAPI dependency: extract current user from session cookie.""" + from fastapi import Request + session = request.cookies.get("flowdeck_session") + if session: + return SessionManager.decode_session(session) + return None diff --git a/app/config.py b/app/config.py index 3620d4c..8cfeaf3 100644 --- a/app/config.py +++ b/app/config.py @@ -17,6 +17,12 @@ class Settings(BaseSettings): gitea_oauth_client_secret: str = "" gitea_webhook_secret: str = "" + # OAuth2 + oauth_redirect_uri: str = "http://localhost:8080/auth/callback" + + # Webhook + webhook_base_url: str = "http://localhost:8080" + # App app_secret_key: str = "change-me-to-random" app_host: str = "0.0.0.0" @@ -24,6 +30,10 @@ class Settings(BaseSettings): log_level: str = "INFO" default_lang: str = "fr" + # Rate limiting + rate_limit_enabled: bool = True + rate_limit_requests: int = 60 # per minute + # Database database_url: str = "sqlite:////data/flowdeck.db" diff --git a/app/db.py b/app/db.py index 6a6a616..10cb81c 100644 --- a/app/db.py +++ b/app/db.py @@ -20,6 +20,24 @@ def init_db(): conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + is_admin BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS user_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gitea_user_id INTEGER NOT NULL UNIQUE, + gitea_token TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS boards ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_owner TEXT NOT NULL, @@ -61,6 +79,25 @@ def init_db(): close_issue BOOLEAN NOT NULL DEFAULT 0, UNIQUE(board_id, column_name) ); + + CREATE TABLE IF NOT EXISTS checklists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id INTEGER NOT NULL, + gitea_issue_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT 'Checklist', + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(board_id, gitea_issue_id, title) + ); + + CREATE TABLE IF NOT EXISTS checklist_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, + content TEXT NOT NULL DEFAULT '', + checked BOOLEAN NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); """) conn.commit() diff --git a/app/main.py b/app/main.py index b47bf18..daba613 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,13 @@ import logging from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from app.config import settings from app.db import init_db -from app.routers import dashboard, board, notes, api +from app.middleware.csrf import CSRFMiddleware +from app.routers import dashboard, board, notes, api, auth, webhooks +from app.services.gitea_client import gitea # ── logging ── @@ -23,12 +26,24 @@ logger = logging.getLogger(__name__) app = FastAPI( title="FlowDeck", - version="0.2.0", + version="0.3.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, ) -# ponytail: CORS for LAN access +# ── middleware (order matters) ── + +# Session (for OAuth2 state) +app.add_middleware( + SessionMiddleware, + secret_key=settings.app_secret_key, + max_age=3600, # 1 hour for OAuth state +) + +# CSRF protection +app.add_middleware(CSRFMiddleware) + +# CORS for LAN access app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -38,10 +53,12 @@ app.add_middleware( # ── routers ── +app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(board.router) app.include_router(notes.router) app.include_router(api.router) +app.include_router(webhooks.router) # ── static ── @@ -51,4 +68,11 @@ app.mount("/static", StaticFiles(directory="static"), name="static") @app.on_event("startup") async def startup(): init_db() - logger.info("FlowDeck started on port %d", settings.app_port) + # Seed admin user for non-OAuth mode + from app.db import get_conn + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)" + ) + conn.commit() + logger.info("FlowDeck v0.3.0 started on port %d", settings.app_port) diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..aa51a09 --- /dev/null +++ b/app/middleware/__init__.py @@ -0,0 +1,2 @@ +"""FlowDeck — Custom middleware.""" +from app.middleware.csrf import CSRFMiddleware diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py new file mode 100644 index 0000000..caed247 --- /dev/null +++ b/app/middleware/csrf.py @@ -0,0 +1,49 @@ +"""FlowDeck — CSRF protection middleware.""" +from __future__ import annotations + +import secrets + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.requests import Request + + +class CSRFMiddleware(BaseHTTPMiddleware): + """Lightweight CSRF protection for state-changing requests. + + All POST/PUT/PATCH/DELETE requests must include X-CSRF-Token + header matching the csrf_token cookie. + """ + + SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + EXCLUDED_PATHS = {"/api/webhook", "/auth/callback"} + + async def dispatch(self, request: Request, call_next): + # Webhook receiver and OAuth callback are exempt + if request.url.path in self.EXCLUDED_PATHS: + return await call_next(request) + + if request.method in self.SAFE_METHODS: + response = await call_next(request) + # Set CSRF cookie if not present + if "csrf_token" not in request.cookies: + response.set_cookie( + "csrf_token", + secrets.token_hex(32), + httponly=False, # Must be readable by JS + samesite="lax", + max_age=86400, + ) + return response + + # Validate CSRF for state-changing methods + csrf_cookie = request.cookies.get("csrf_token", "") + csrf_header = request.headers.get("X-CSRF-Token", "") + + if not csrf_cookie or not csrf_header or not secrets.compare_digest(csrf_cookie, csrf_header): + return JSONResponse( + {"detail": "CSRF validation failed"}, + status_code=403, + ) + + return await call_next(request) diff --git a/app/routers/api.py b/app/routers/api.py index f5df7cc..9ffa243 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -4,8 +4,9 @@ from __future__ import annotations import json import logging from datetime import datetime +from typing import Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Request from app.config import settings from app.db import get_conn @@ -14,6 +15,25 @@ from app.services.gitea_client import gitea logger = logging.getLogger(__name__) router = APIRouter(tags=["api"], prefix="/api") +# ── Simple rate limiter (in-memory, per-IP) ── +_rate_limit_store: dict[str, tuple[float, int]] = {} + + +def _check_rate_limit(request: Request) -> bool: + """Simple sliding window rate limiter. Returns True if allowed.""" + if not settings.rate_limit_enabled: + return True + ip = request.client.host if request.client else "unknown" + now = datetime.utcnow().timestamp() + window_start, count = _rate_limit_store.get(ip, (0, 0)) + if now - window_start > 60: + _rate_limit_store[ip] = (now, 1) + return True + if count >= settings.rate_limit_requests: + return False + _rate_limit_store[ip] = (window_start, count + 1) + return True + @router.get("/health") async def health(): @@ -34,7 +54,7 @@ async def health(): return { "status": "ok" if (db_ok and gitea_ok) else "degraded", - "version": "0.2.0", + "version": "0.3.0", "db": db_ok, "gitea": gitea_ok, } @@ -47,11 +67,13 @@ async def stats(): board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"] card_count = conn.execute("SELECT COUNT(*) as c FROM cards").fetchone()["c"] note_count = conn.execute("SELECT COUNT(*) as c FROM notes").fetchone()["c"] + user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"] return { "boards": board_count, "cards": card_count, "notes": note_count, + "users": user_count, } @@ -82,12 +104,16 @@ async def projects(search: str = Query(default=""), show_archived: bool = Query( @router.post("/move") async def move_card( + request: Request, owner: str = Query(...), repo: str = Query(...), issue_id: int = Query(...), column: str = Query(...), ): """Move a card to a column. Updates DB position + Gitea labels if mapped.""" + if not _check_rate_limit(request): + raise HTTPException(status_code=429, detail="Rate limit exceeded") + with get_conn() as conn: board = conn.execute( "SELECT id FROM boards WHERE project_owner=? AND project_name=?", @@ -98,7 +124,6 @@ async def move_card( board_id = board["id"] - # Upsert card existing = conn.execute( "SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?", (board_id, issue_id), @@ -115,7 +140,6 @@ async def move_card( (board_id, issue_id, column), ) - # Check label mapping for sync to Gitea mapping = conn.execute( "SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?", (board_id, column), @@ -123,16 +147,13 @@ async def move_card( conn.commit() - # Sync to Gitea if mapping: try: if mapping["close_issue"]: await gitea.close_issue(owner, repo, issue_id) else: - # Get current labels, add mapped label issue = await gitea.get_issue(owner, repo, issue_id) current_labels = [lbl["name"] for lbl in issue.get("labels", [])] - # Remove other status labels, add new one status_labels = await _get_status_labels(owner, repo, board_id) filtered = [l for l in current_labels if l not in status_labels] filtered.append(mapping["gitea_label"]) @@ -144,7 +165,6 @@ async def move_card( async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]: - """Get all status labels configured for this board.""" with get_conn() as conn: rows = conn.execute( "SELECT gitea_label FROM col_mapping WHERE board_id=?", @@ -185,7 +205,6 @@ async def set_col_mapping( @router.get("/board-config/{owner}/{repo}") async def get_board_config(owner: str, repo: str): - """Get board columns and WIP limits.""" with get_conn() as conn: board = conn.execute( "SELECT * FROM boards WHERE project_owner=? AND project_name=?", @@ -212,7 +231,6 @@ async def update_board_config( columns: str = Query(default=""), wip_limits: str = Query(default=""), ): - """Update board columns and WIP limits.""" with get_conn() as conn: board = conn.execute( "SELECT id FROM boards WHERE project_owner=? AND project_name=?", @@ -230,3 +248,265 @@ async def update_board_config( ) conn.commit() return {"status": "ok"} + + +# ── v0.5.0: Issue CRUD ── + +@router.post("/issues/{owner}/{repo}") +async def create_issue( + request: Request, + owner: str, + repo: str, + title: str = Query(...), + body: str = Query(default=""), + labels: str = Query(default=""), + milestone: str = Query(default=""), + assignee: str = Query(default=""), +): + """Create a new issue in Gitea and add card to board.""" + if not _check_rate_limit(request): + raise HTTPException(status_code=429, detail="Rate limit exceeded") + + label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None + milestone_id = int(milestone) if milestone.strip().isdigit() else None + + issue = await gitea.create_issue( + owner, repo, title, body, + labels=label_ids, milestone=milestone_id, assignee=assignee, + ) + + # Add card to local board + with get_conn() as conn: + board = conn.execute( + "SELECT id FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if board: + conn.execute( + "INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, 'Backlog', 0)", + (board["id"], issue["number"]), + ) + conn.commit() + + return {"status": "ok", "issue": issue} + + +@router.patch("/issues/{owner}/{repo}/{issue_id}") +async def update_issue_api( + request: Request, + owner: str, + repo: str, + issue_id: int, + title: str = Query(default=""), + body: str = Query(default=""), + state: str = Query(default=""), + labels: str = Query(default=""), + milestone: str = Query(default=""), + assignee: str = Query(default=""), +): + """Update an issue (partial update).""" + if not _check_rate_limit(request): + raise HTTPException(status_code=429, detail="Rate limit exceeded") + + kwargs = {} + if title: + kwargs["title"] = title + if body: + kwargs["body"] = body + if state: + kwargs["state"] = state + if labels: + kwargs["labels"] = labels.split(",") + if milestone and milestone.strip().isdigit(): + kwargs["milestone"] = int(milestone) + if assignee: + kwargs["assignees"] = [assignee] + + issue = await gitea.update_issue(owner, repo, issue_id, **kwargs) + return {"status": "ok", "issue": issue} + + +# ── v0.5.0: Card detail ── + +@router.get("/issues/{owner}/{repo}/{issue_id}") +async def get_issue_detail(owner: str, repo: str, issue_id: int): + """Get full issue details + comments.""" + try: + issue = await gitea.get_issue(owner, repo, issue_id) + comments = await gitea.get_issue_comments(owner, repo, issue_id) + except Exception as e: + logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e) + raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") + + # Get checklists from local DB + with get_conn() as conn: + board = conn.execute( + "SELECT id FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + board_id = board["id"] if board else None + + checklists = [] + if board_id: + rows = conn.execute( + "SELECT * FROM checklists WHERE board_id=? AND gitea_issue_id=? ORDER BY position", + (board_id, issue_id), + ).fetchall() + for cl in rows: + items = conn.execute( + "SELECT * FROM checklist_items WHERE checklist_id=? ORDER BY position", + (cl["id"],), + ).fetchall() + checklists.append({ + **dict(cl), + "items": [dict(it) for it in items], + }) + + card = conn.execute( + "SELECT * FROM cards WHERE board_id=? AND gitea_issue_id=?", + (board_id, issue_id), + ).fetchone() if board_id else None + + return { + "issue": issue, + "comments": comments, + "checklists": checklists, + "card": dict(card) if card else None, + } + + +# ── v0.5.0: Checklists ── + +@router.post("/checklists/{owner}/{repo}/{issue_id}") +async def create_checklist( + owner: str, + repo: str, + issue_id: int, + title: str = Query(default="Checklist"), +): + """Create a new checklist for an issue.""" + with get_conn() as conn: + board = conn.execute( + "SELECT id FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if not board: + raise HTTPException(status_code=404, detail="Board not found") + + cur = conn.execute( + "INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, ?, ?)", + (board["id"], issue_id, title), + ) + conn.commit() + return {"status": "ok", "checklist_id": cur.lastrowid} + + +@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}") +async def add_checklist_item( + owner: str, + repo: str, + issue_id: int, + checklist_id: int, + content: str = Query(...), +): + """Add an item to a checklist.""" + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO checklist_items (checklist_id, content) VALUES (?, ?)", + (checklist_id, content), + ) + conn.commit() + return {"status": "ok", "item_id": cur.lastrowid} + + +@router.patch("/checklist-items/{item_id}") +async def toggle_checklist_item( + item_id: int, + checked: bool = Query(default=False), + content: str = Query(default=""), +): + """Toggle or update a checklist item.""" + with get_conn() as conn: + if content: + conn.execute( + "UPDATE checklist_items SET checked=?, content=? WHERE id=?", + (int(checked), content, item_id), + ) + else: + conn.execute( + "UPDATE checklist_items SET checked=? WHERE id=?", + (int(checked), item_id), + ) + conn.commit() + return {"status": "ok"} + + +@router.delete("/checklist-items/{item_id}") +async def delete_checklist_item(item_id: int): + """Delete a checklist item.""" + with get_conn() as conn: + conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,)) + conn.commit() + return {"status": "ok"} + + +@router.delete("/checklists/{checklist_id}") +async def delete_checklist(checklist_id: int): + """Delete a checklist and all its items.""" + with get_conn() as conn: + conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,)) + conn.execute("DELETE FROM checklists WHERE id=?", (checklist_id,)) + conn.commit() + return {"status": "ok"} + + +# ── v0.5.0: Card priority & due date ── + +@router.post("/card/{owner}/{repo}/{issue_id}") +async def update_card( + owner: str, + repo: str, + issue_id: int, + priority: str = Query(default=""), + due_date: str = Query(default=""), +): + """Update card metadata (priority, due date).""" + with get_conn() as conn: + board = conn.execute( + "SELECT id FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if not board: + raise HTTPException(status_code=404, detail="Board not found") + + updates = [] + params = [] + if priority: + updates.append("priority=?") + params.append(priority) + if due_date: + updates.append("due_date=?") + params.append(due_date) + + if updates: + updates.append("updated_at=CURRENT_TIMESTAMP") + params.extend([board["id"], issue_id]) + conn.execute( + f"""UPDATE cards SET {', '.join(updates)} WHERE board_id=? AND gitea_issue_id=?""", + params, + ) + conn.commit() + + return {"status": "ok"} + + +# ── v0.5.0: Collaborators for assignee selector ── + +@router.get("/collaborators/{owner}/{repo}") +async def get_collaborators(owner: str, repo: str): + """Get repo collaborators (for assignee dropdown).""" + try: + collaborators = await gitea.get_collaborators(owner, repo) + return {"collaborators": collaborators} + except Exception as e: + return {"collaborators": [], "error": str(e)} diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..b7a2f2c --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,110 @@ +"""FlowDeck — Auth routes: login, callback, logout.""" +from __future__ import annotations + +import logging +import secrets + +from fastapi import APIRouter, Request, Query +from fastapi.responses import RedirectResponse, HTMLResponse + +from app.auth.session import SessionManager +from app.auth.oauth import gitea_oauth +from app.config import settings + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["auth"], prefix="/auth") + + +@router.get("/login") +async def login(request: Request): + """Redirect to Gitea OAuth2 authorize page.""" + if not gitea_oauth.enabled: + # Fallback: use global token, create a fake session + from app.db import get_conn + with get_conn() as conn: + user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone() + if not user: + conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')") + conn.commit() + user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone() + user_data = dict(user) + session = SessionManager.create_session(user_data) + response = RedirectResponse(url="/", status_code=302) + response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax") + return response + + state = secrets.token_hex(32) + request.session["oauth_state"] = state + auth_url = gitea_oauth.get_authorize_url(state) + return RedirectResponse(url=auth_url, status_code=302) + + +@router.get("/callback") +async def callback( + request: Request, + code: str = Query(...), + state: str = Query(...), +): + """Handle OAuth2 callback from Gitea.""" + # Validate state + expected_state = request.session.get("oauth_state", "") + if not expected_state or state != expected_state: + return HTMLResponse("