diff --git a/app/middleware/security.py b/app/middleware/security.py
new file mode 100644
index 0000000..ac7aa98
--- /dev/null
+++ b/app/middleware/security.py
@@ -0,0 +1,141 @@
+"""FlowDeck — Security middleware: CSP headers + rate limiting."""
+from __future__ import annotations
+
+import time
+from collections import defaultdict
+
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+# ── Constants ────────────────────────────────────────────────
+
+# Allowed extensions for file uploads
+ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
+ # Images
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
+ # Documents
+ ".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
+ # Code
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
+ ".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
+ ".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
+ ".swift", ".kt", ".scala", ".r",
+ # Archives
+ ".zip", ".tar", ".gz", ".rar", ".7z",
+ # Misc
+ ".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
+})
+
+MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
+
+
+def validate_upload(filename: str, size: int) -> str | None:
+ """Validate upload filename and size. Returns error message or None."""
+ if size > MAX_UPLOAD_SIZE:
+ return f"File '{filename}' exceeds maximum size of 10 MB"
+ ext = _ext(filename)
+ if ext and ext not in ALLOWED_EXTENSIONS:
+ return f"File extension '{ext}' is not allowed"
+ return None
+
+
+def _ext(filename: str) -> str:
+ """Extract lowercase extension from filename."""
+ if "." in filename:
+ return "." + filename.rsplit(".", 1)[-1].lower()
+ return ""
+
+
+# ── Content-Security-Policy Middleware ────────────────────────
+
+class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
+ """Sets Content-Security-Policy headers on all HTML responses.
+
+ A permissive-yet-safe policy for a Notion-style app that needs:
+ - inline scripts (Alpine.js, HTMX)
+ - inline styles
+ - font loading
+ - images from various sources
+ - media (audio/video)
+ - websocket connections for HMR/SSE
+ """
+
+ CSP_HEADER = "Content-Security-Policy"
+ CSP_VALUE = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data: blob: https:; "
+ "font-src 'self' data:; "
+ "connect-src 'self' https: wss:; "
+ "media-src 'self' blob:; "
+ "frame-src 'self'; "
+ "object-src 'none'; "
+ "base-uri 'self'; "
+ "form-action 'self'; "
+ )
+
+ async def dispatch(self, request: Request, call_next):
+ response = await call_next(request)
+ # Only set CSP on HTML responses
+ content_type = response.headers.get("content-type", "")
+ if "text/html" in content_type:
+ response.headers[self.CSP_HEADER] = self.CSP_VALUE
+ return response
+
+
+# ── Rate Limiting Middleware ──────────────────────────────────
+
+class RateLimitMiddleware(BaseHTTPMiddleware):
+ """Simple in-memory sliding-window rate limiter per IP.
+
+ Default: 100 requests per minute per IP for API routes.
+ Non-API routes (HTML pages, static files) are not rate-limited.
+ """
+
+ # Paths that should be rate-limited
+ RATE_LIMITED_PREFIXES: tuple[str, ...] = (
+ "/api/", "/board/api/", "/auth/",
+ )
+
+ # Paths exempt from rate limiting even under an API prefix
+ EXEMPT_PATHS: frozenset[str] = frozenset({
+ "/api/health",
+ "/api/frontend-error",
+ "/api/frontend-errors",
+ })
+
+ def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
+ super().__init__(app)
+ self.max_requests = max_requests
+ self.window_seconds = window_seconds
+ self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
+
+ async def dispatch(self, request: Request, call_next):
+ path = request.url.path
+
+ # Only rate-limit API routes
+ if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
+ return await call_next(request)
+
+ # Exempt health check and error capture
+ if path in self.EXEMPT_PATHS:
+ return await call_next(request)
+
+ ip = request.client.host if request.client else "unknown"
+ now = time.time()
+
+ window_start, count = self._store[ip]
+ if now - window_start > self.window_seconds:
+ self._store[ip] = (now, 1)
+ return await call_next(request)
+
+ if count >= self.max_requests:
+ return JSONResponse(
+ {"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
+ status_code=429,
+ )
+
+ self._store[ip] = (window_start, count + 1)
+ return await call_next(request)
diff --git a/app/models/requests.py b/app/models/requests.py
new file mode 100644
index 0000000..59aa9e2
--- /dev/null
+++ b/app/models/requests.py
@@ -0,0 +1,95 @@
+"""FlowDeck — Pydantic request models for API validation."""
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import UploadFile
+from pydantic import BaseModel, Field, model_validator
+
+from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
+
+
+# ── File Save ────────────────────────────────────────────────
+
+class FileSaveRequest(BaseModel):
+ """Request model for saving/updating a file via Gitea."""
+ path: str = Field(..., min_length=1, description="File path in the repository")
+ content: str = Field(..., description="File content (UTF-8 encoded)")
+ message: str = Field(default="Update via FlowDeck", description="Commit message")
+ sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
+
+ @model_validator(mode="after")
+ def validate_path_extension(self):
+ ext = _ext(self.path)
+ if ext and ext not in ALLOWED_EXTENSIONS:
+ raise ValueError(f"File extension '{ext}' is not allowed")
+ return self
+
+
+# ── Upload ───────────────────────────────────────────────────
+
+class UploadValidationResult(BaseModel):
+ """Result of validating an upload."""
+ filename: str
+ size: int
+ extension: str
+ valid: bool
+ error: Optional[str] = None
+
+
+def validate_upload_request(file: UploadFile) -> Optional[str]:
+ """Validate an uploaded file (size + extension). Returns error message or None."""
+ # Size check — we can't read the full file without a size attribute,
+ # but Starlette's UploadFile has a size property from Content-Length
+ if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
+ return f"File '{file.filename}' exceeds maximum size of 10 MB"
+
+ # Extension check
+ if file.filename:
+ ext = _ext(file.filename)
+ if ext and ext not in ALLOWED_EXTENSIONS:
+ return f"File extension '{ext}' is not allowed"
+
+ return None
+
+
+# ── Issue Create / Update ────────────────────────────────────
+
+class IssueCreateRequest(BaseModel):
+ """Request model for creating a Gitea issue."""
+ title: str = Field(..., min_length=1, max_length=500)
+ body: str = Field(default="")
+ labels: str = Field(default="", description="Comma-separated label IDs")
+ milestone: str = Field(default="", description="Milestone ID")
+ assignee: str = Field(default="")
+
+
+class IssueUpdateRequest(BaseModel):
+ """Request model for updating a Gitea issue (partial update)."""
+ title: Optional[str] = Field(default=None, max_length=500)
+ body: Optional[str] = Field(default=None)
+ state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
+ labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
+ milestone: Optional[str] = Field(default=None)
+ assignee: Optional[str] = Field(default=None)
+
+
+# ── Card Move ────────────────────────────────────────────────
+
+class CardMoveRequest(BaseModel):
+ """Request model for moving a card between columns."""
+ owner: str = Field(..., min_length=1)
+ repo: str = Field(..., min_length=1)
+ issue_id: int = Field(..., gt=0)
+ column: str = Field(..., min_length=1)
+
+
+# ── Column Mapping ───────────────────────────────────────────
+
+class ColMappingRequest(BaseModel):
+ """Request model for column-to-label mapping."""
+ owner: str = Field(..., min_length=1)
+ repo: str = Field(..., min_length=1)
+ column: str = Field(..., min_length=1)
+ gitea_label: str = Field(..., min_length=1)
+ close_issue: bool = Field(default=False)
diff --git a/app/models/responses.py b/app/models/responses.py
new file mode 100644
index 0000000..a5d339c
--- /dev/null
+++ b/app/models/responses.py
@@ -0,0 +1,38 @@
+"""FlowDeck — Standardized response models."""
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from pydantic import BaseModel
+
+
+class ErrorResponse(BaseModel):
+ """Standardized error response.
+
+ Example:
+ ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
+ """
+ error: str
+ detail: Optional[str] = None
+
+ model_config = {
+ "json_schema_extra": {
+ "example": {"error": "Not found", "detail": "Board not found"}
+ }
+ }
+
+
+class SuccessResponse(BaseModel):
+ """Standardized success response.
+
+ Example:
+ SuccessResponse(status="ok", data={"issue_id": 42})
+ """
+ status: str = "ok"
+ data: Optional[dict[str, Any]] = None
+
+ model_config = {
+ "json_schema_extra": {
+ "example": {"status": "ok", "data": {"issue_id": 42, "column": "Done"}}
+ }
+ }
diff --git a/app/templates/base.html b/app/templates/base.html
index ecc072d..ac51baa 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -5,6 +5,7 @@
@@ -662,9 +648,9 @@ document.addEventListener('alpine:init', function() {
await this.loadAdminUsers();
} else {
var d = await r.json();
- alert(d.error || 'Failed to create user');
+ window.showToast(d.error || 'Failed to create user', 'error');
}
- } catch(e) { alert('Network error'); }
+ } catch(e) { window.showToast('Network error', 'error'); }
},
async adminUpdateUser() {
diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html
index 9db6003..12e28c9 100644
--- a/app/templates/workspaces.html
+++ b/app/templates/workspaces.html
@@ -31,8 +31,8 @@
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.ws-card-name{font-size:15px;font-weight:500;margin-bottom:2px;}
-.ws-card-sub{font-size:12px;color:var(--text-tertiary);margin-bottom:4px;}
-.ws-card-count{font-size:12px;color:var(--text-tertiary);}
+.ws-card-sub{font-size:12px;color:var(--text-dim);margin-bottom:4px;}
+.ws-card-count{font-size:12px;color:var(--text-dim);}
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
.ws-card:hover .ws-card-actions{opacity:1;}
@@ -46,33 +46,12 @@
.gitea-connect:hover{text-decoration:underline;}
.gitea-loading{color:var(--text-dim);font-size:13px;padding:12px 0;}
-.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
-.dialog-box{background:#1F1F1F;border:1px solid var(--border);border-radius:18px;padding:24px;width:400px;max-width:90vw;}
-.dialog-box h3{margin-bottom:16px;}
-.dialog-input{width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px;outline:none;}
-.dialog-input:focus{border-color:var(--accent);}
-.dialog-actions{display:flex;gap:8px;justify-content:flex-end;}
-.btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
-.btn-primary{background:var(--accent);color:#fff;}
-.btn-primary:hover{background:var(--accent-hover);}
-.btn-secondary{background:var(--bg-tertiary);color:var(--text);}
-.btn-secondary:hover{background:var(--bg-hover);}
-.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
-.btn-danger:hover{background:rgba(255,80,80,.25);}
-.btn-new{border:1px dashed var(--border-strong);background:transparent;color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-height:120px;}
-.btn-new:hover{background:var(--bg-tertiary);color:var(--text);}
-.btn-sm{padding:4px 8px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-dim);cursor:pointer;}
-.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);}
/* Org tabs */
.org-tab{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:13px;color:var(--text-dim);background:transparent;border:1px solid var(--border);cursor:pointer;white-space:nowrap;transition:all 100ms;}
.org-tab:hover{background:var(--bg-tertiary);color:var(--text);}
.org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);}
.search-input:focus{border-color:var(--accent);}
-
-/* ── Skeleton loading animation ── */
-.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
-@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
diff --git a/static/css/design-tokens.css b/static/css/design-tokens.css
new file mode 100644
index 0000000..1dd0ae6
--- /dev/null
+++ b/static/css/design-tokens.css
@@ -0,0 +1,500 @@
+/* ═══════════════════════════════════════════════════════════
+ FlowDeck — Design Tokens & System
+ Theme variables, light/dark mode, skeletons, toasts, shared UI
+ ═══════════════════════════════════════════════════════════ */
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+/* ═══════════ THEME VARIABLES (Dark — default) ═══════════ */
+:root {
+ /* ===== Background ===== */
+ --bg-primary: #191919;
+ --bg-secondary: #1f1f1f;
+ --bg-sidebar: #222222;
+ --bg-tertiary: #333333;
+ --bg-hover: #2a2a2a;
+ --bg-active: #2F2E2E;
+ --bg-modal: #252525;
+
+ /* ===== Text ===== */
+ --text-primary: #FFFFFF;
+ --text-secondary: #A0A0A0;
+ --text-dim: #6B6B6B;
+ --text-muted: #4A4A4A;
+ --text: #FFFFFF;
+
+ /* ===== Borders ===== */
+ --border: #333333;
+ --border-color: rgba(255,255,255,0.06);
+ --border-light: #3a3a3a;
+ --border-strong: #4a4a4a;
+
+ /* ===== Accent Colors ===== */
+ --blue: #3366CC;
+ --blue-bg: #1a2744;
+ --green: #00CC66;
+ --green-bg: #1a332a;
+ --yellow: #FFD700;
+ --orange: #D9730D;
+ --red: #E03E3E;
+ --purple: #9B72F0;
+ --pink: #E255A1;
+ --gray: #787774;
+ --brown: #9D6E4A;
+
+ --accent: #2383E2;
+ --accent-hover: #1a6bc0;
+ --danger: #E03E3E;
+ --danger-hover: #C93535;
+
+ /* ===== Typography ===== */
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'SF Mono', 'Fira Code', monospace;
+
+ /* ===== Dimensions ===== */
+ --sidebar-width: 240px;
+ --sidebar-collapsed: 0px;
+ --topbar-height: 40px;
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius: 8px;
+
+ /* ===== Spacing ===== */
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 12px;
+ --space-lg: 16px;
+ --space-xl: 24px;
+
+ /* ===== Transitions ===== */
+ --transition: 0.15s ease;
+
+ /* ===== Toast colors ===== */
+ --toast-success-bg: #0F7B6C;
+ --toast-success-text: #FFFFFF;
+ --toast-error-bg: #E03E3E;
+ --toast-error-text: #FFFFFF;
+ --toast-info-bg: #2383E2;
+ --toast-info-text: #FFFFFF;
+}
+
+/* ═══════════ LIGHT THEME ═══════════ */
+[data-theme="light"] {
+ --bg-primary: #FFFFFF;
+ --bg-secondary: #FBFBFA;
+ --bg-sidebar: #F7F6F3;
+ --bg-tertiary: #EDEDEC;
+ --bg-hover: #EFEFEE;
+ --bg-active: #E8E7E4;
+ --bg-modal: #FFFFFF;
+
+ --text-primary: #1A1A1A;
+ --text-secondary: #6B6B6B;
+ --text-dim: #9B9B9B;
+ --text-muted: #B0B0B0;
+ --text: #37352F;
+
+ --border: #E0E0E0;
+ --border-color: rgba(0,0,0,0.06);
+ --border-light: #EDEDEC;
+ --border-strong: #CCCBC8;
+
+ --blue-bg: #E8F0FE;
+ --green-bg: #E6F4EA;
+}
+
+/* ═══════════ RESET ADDITIONS ═══════════ */
+[x-cloak] { display: none !important; }
+
+/* ═══════════ SKELETON LOADING ═══════════ */
+.skeleton {
+ background: var(--bg-tertiary);
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
+ border-radius: 4px;
+}
+
+@keyframes skeleton-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: .5; }
+}
+
+/* Skeleton variants for different contexts */
+.skeleton-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px;
+ pointer-events: none;
+}
+
+.skeleton-card .skeleton-line {
+ height: 14px;
+ margin-bottom: 10px;
+ border-radius: 4px;
+}
+
+.skeleton-card .skeleton-line:last-child { margin-bottom: 0; }
+
+.skeleton-file {
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-secondary);
+ max-height: 70vh;
+ overflow: hidden;
+}
+
+.skeleton-line {
+ background: var(--bg-tertiary);
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
+ border-radius: 4px;
+}
+
+.skeleton-table {
+ overflow: hidden;
+}
+
+.skeleton-table .skeleton-line {
+ height: 40px;
+ margin-bottom: 2px;
+ border-radius: 0;
+}
+
+/* ═══════════ TOAST NOTIFICATIONS ═══════════ */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ pointer-events: none;
+}
+
+.toast {
+ padding: 12px 20px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 500;
+ font-family: var(--font-sans);
+ max-width: 400px;
+ box-shadow: 0 8px 24px rgba(0,0,0,.4);
+ pointer-events: auto;
+ animation: toast-slide-in 0.25s ease-out;
+ backdrop-filter: blur(4px);
+}
+
+.toast-success {
+ background: var(--toast-success-bg);
+ color: var(--toast-success-text);
+}
+
+.toast-error {
+ background: var(--toast-error-bg);
+ color: var(--toast-error-text);
+}
+
+.toast-info {
+ background: var(--toast-info-bg);
+ color: var(--toast-info-text);
+}
+
+@keyframes toast-slide-in {
+ from {
+ opacity: 0;
+ transform: translateY(12px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+/* ═══════════ SHARED BUTTONS ═══════════ */
+.btn {
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ font-weight: 500;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-dim);
+ font-family: var(--font-sans);
+ transition: background var(--transition), color var(--transition);
+}
+
+.btn:hover {
+ background: var(--bg-tertiary);
+ color: var(--text);
+}
+
+.btn-primary {
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ border: none;
+ font-weight: 500;
+ font-family: var(--font-sans);
+ background: var(--accent);
+ color: #fff;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-danger {
+ background: rgba(255,80,80,.15);
+ color: var(--danger);
+ border: none;
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: var(--font-sans);
+}
+
+.btn-danger:hover {
+ background: rgba(255,80,80,.25);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: var(--font-sans);
+ transition: background var(--transition);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-hover);
+}
+
+.btn-sm {
+ padding: 4px 8px;
+ font-size: 11px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-family: var(--font-sans);
+}
+
+.btn-sm:hover {
+ background: var(--bg-tertiary);
+ color: var(--text);
+}
+
+.btn-sm-danger {
+ color: var(--danger);
+ border-color: rgba(255,80,80,.3);
+}
+
+.btn-new {
+ border: 1px dashed var(--border-strong);
+ background: transparent;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius);
+}
+
+.btn-new:hover {
+ background: var(--bg-tertiary);
+ color: var(--text);
+}
+
+/* ═══════════ SHARED FORMS ═══════════ */
+.settings-input {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 13px;
+ padding: 6px 10px;
+ outline: none;
+ width: 100%;
+ font-family: var(--font-sans);
+}
+
+.settings-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(35,131,226,.25);
+}
+
+.settings-select {
+ background: var(--bg-tertiary);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 13px;
+ outline: none;
+ cursor: pointer;
+ min-width: 140px;
+ font-family: var(--font-sans);
+}
+
+.settings-select:focus {
+ border-color: var(--accent);
+}
+
+/* ═══════════ SHARED DIALOGS ═══════════ */
+.dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 500;
+}
+
+.dialog-box {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ padding: 24px;
+ width: 400px;
+ max-width: 90vw;
+}
+
+.dialog-box h3 {
+ margin-bottom: 16px;
+ color: var(--text);
+}
+
+.dialog-input {
+ width: 100%;
+ padding: 10px 12px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 14px;
+ margin-bottom: 12px;
+ outline: none;
+ font-family: var(--font-sans);
+}
+
+.dialog-input:focus {
+ border-color: var(--accent);
+}
+
+.dialog-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+/* ═══════════ MODAL ═══════════ */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 999;
+}
+
+.modal-box {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 24px;
+ min-width: 320px;
+ box-shadow: 0 12px 40px rgba(0,0,0,.8);
+}
+
+.modal-box h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+/* ═══════════ DROPDOWN ═══════════ */
+.dropdown-panel {
+ display: none;
+ position: absolute;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0,0,0,.5);
+ z-index: 300;
+ min-width: 180px;
+ padding: 4px 0;
+}
+
+.dropdown-panel.visible {
+ display: block;
+}
+
+.dropdown-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ font-size: 13px;
+ color: var(--text);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.dropdown-option:hover {
+ background: var(--bg-hover);
+}
+
+/* ═══════════ EMPTY STATE ═══════════ */
+.empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--text-dim);
+}
+
+.empty-state h2 {
+ font-size: 18px;
+ margin-bottom: 8px;
+ color: var(--text);
+}
+
+/* ═══════════ TEXT UTILITIES ═══════════ */
+.text-dim { color: var(--text-dim); }
+.text-xs { font-size: 12px; }
+.text-secondary { color: var(--text-secondary); }
+
+/* ═══════════ PASSWORD TOGGLE ═══════════ */
+.pw-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.pw-toggle {
+ position: absolute;
+ right: 6px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 4px;
+ color: var(--text-dim);
+ font-family: var(--font-sans);
+}
diff --git a/tests/test_app.py b/tests/test_app.py
index 7a608b3..c388aeb 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1006,3 +1006,844 @@ def test_pwa_manifest(client):
data = resp.json()
assert data["name"] == "FlowDeck"
assert data["display"] == "standalone"
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: Gitea Upload API ──
+# ══════════════════════════════════════════════════════
+
+def test_upload_no_auth(client):
+ """POST /api/gitea/projects/owner/repo/upload — 401 without Gitea linked."""
+ resp = client.post("/api/gitea/projects/testowner/testrepo/upload")
+ assert resp.status_code == 401
+ assert "not linked" in resp.json()["detail"].lower() or "gitea" in resp.json()["detail"].lower()
+
+
+def test_upload_missing_file(client):
+ """POST /api/gitea/projects/owner/repo/upload — 400 when no file provided."""
+ # Create a user with an OAuth token so gitea status passes
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('uploadtest', 'Upload Test', 'up@test.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='uploadtest'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'fake-token')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "uploadtest", "is_admin": 0})
+ # Send multipart form without file field
+ resp = client.post(
+ "/api/gitea/projects/testowner/testrepo/upload",
+ data={"folder": "docs"},
+ cookies={"flowdeck_session": session},
+ )
+ # 400 or 401 — depends on whether multipart parsing fails vs auth check
+ assert resp.status_code in (400, 401)
+ # cleanup
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_upload_with_session_no_file(client):
+ """POST upload with valid session but no file field → 400."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('upuser2', 'Up2', 'up2@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='upuser2'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'tok2')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "upuser2", "is_admin": 0})
+ # multipart without file → 400
+ resp = client.post(
+ "/api/gitea/projects/owner/repo/upload",
+ files=[],
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code in (400, 401, 422)
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: Labels Sync ──
+# ══════════════════════════════════════════════════════
+
+def test_sync_labels_no_auth(client):
+ """POST /api/gitea/projects/owner/repo/sync-labels — 401 without session."""
+ resp = client.post("/api/gitea/projects/testowner/testrepo/sync-labels")
+ assert resp.status_code == 401
+
+
+def test_sync_labels_with_session_no_gitea(client):
+ """POST sync-labels — 401 when session exists but no Gitea OAuth token."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('syncuser', 'Sync', 's@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='syncuser'").fetchone()["id"]
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "syncuser", "is_admin": 0})
+ resp = client.post(
+ "/api/gitea/projects/owner/repo/sync-labels",
+ cookies={"flowdeck_session": session},
+ )
+ # 401 — requires Gitea OAuth token
+ assert resp.status_code in (401, 502)
+ with get_conn() as conn:
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_sync_labels_with_gitea_token(client):
+ """POST sync-labels — with session + Gitea OAuth token (triggers Gitea call)."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('sync2', 'Sync2', 's2@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='sync2'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'sync-token')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "sync2", "is_admin": 0})
+ resp = client.post(
+ "/api/gitea/projects/owner/repo/sync-labels",
+ cookies={"flowdeck_session": session},
+ )
+ # Will get 502 (Gitea unreachable) or 200 if labels endpoint works
+ assert resp.status_code in (200, 502)
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: Commit History ──
+# ══════════════════════════════════════════════════════
+
+def test_commits_no_auth(client):
+ """GET /api/gitea/projects/owner/repo/commits — 401 without Gitea linked."""
+ resp = client.get("/api/gitea/projects/owner/repo/commits")
+ assert resp.status_code == 401
+
+
+def test_commits_with_path(client):
+ """GET commits?path=file.py — 401 or 502 with session + token."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('commituser', 'Commit', 'c@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='commituser'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'commit-tok')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "commituser", "is_admin": 0})
+ resp = client.get(
+ "/api/gitea/projects/owner/repo/commits?path=src/main.py",
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code in (200, 401, 502)
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_commits_empty_path(client):
+ """GET commits without path param — tests default empty path."""
+ resp = client.get("/api/gitea/projects/owner/repo/commits?path=")
+ assert resp.status_code == 401 # no auth
+
+
+def test_commits_special_chars_path(client):
+ """GET commits with special characters in path."""
+ resp = client.get("/api/gitea/projects/owner/repo/commits?path=src/components/Header%20Component.tsx")
+ assert resp.status_code == 401 # no auth, but shouldn't crash
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: File Create/Update (PUT) ──
+# ══════════════════════════════════════════════════════
+
+def test_file_create_no_auth(client):
+ """PUT /api/gitea/projects/owner/repo/file — 401 without Gitea linked."""
+ resp = client.put("/api/gitea/projects/owner/repo/file", json={
+ "path": "test.md", "content": "# Hello", "message": "test"
+ })
+ assert resp.status_code == 401
+
+
+def test_file_create_missing_path(client):
+ """PUT file without path → 400."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('fileuser', 'File', 'f@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='fileuser'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'file-tok')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "fileuser", "is_admin": 0})
+ resp = client.put(
+ "/api/gitea/projects/owner/repo/file",
+ json={"content": "# No path here", "message": "test"},
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code == 400
+ assert "path" in resp.json()["error"].lower()
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_file_create_with_null_sha(client):
+ """PUT file with sha=null → should create new file (triggers Gitea API call)."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('nullsha', 'NullSHA', 'ns@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='nullsha'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'sha-tok')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "nullsha", "is_admin": 0})
+ resp = client.put(
+ "/api/gitea/projects/owner/repo/file",
+ json={"path": "new-file.md", "content": "# New File", "message": "Create new file", "sha": None},
+ cookies={"flowdeck_session": session},
+ )
+ # 502 = Gitea unreachable (expected) — endpoint logic passes sha=None correctly
+ assert resp.status_code in (200, 502)
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_file_create_with_sha(client):
+ """PUT file with a non-null sha → update mode (triggers Gitea API call)."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('withsha', 'WithSHA', 'ws@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='withsha'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'sha2-tok')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "withsha", "is_admin": 0})
+ resp = client.put(
+ "/api/gitea/projects/owner/repo/file",
+ json={
+ "path": "existing.md", "content": "# Updated", "message": "Update file",
+ "sha": "abc123def456",
+ },
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code in (200, 502)
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_file_create_empty_body(client):
+ """PUT file with empty JSON body → 400 (no path)."""
+ resp = client.put("/api/gitea/projects/owner/repo/file", json={})
+ # Without Gitea token → 401 first
+ assert resp.status_code in (400, 401)
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: Admin User Deletion Cascade ──
+# ══════════════════════════════════════════════════════
+
+def _create_admin_session():
+ """Helper: create an admin user and return (user_id, session_cookie)."""
+ from app.db import get_conn
+ from app.auth.session import SessionManager
+ with get_conn() as conn:
+ import secrets
+ login = f"admintest_{secrets.token_hex(4)}"
+ conn.execute(
+ "INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'Admin Test', ?, 1)",
+ (login, f"{login}@test.com"),
+ )
+ uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
+ conn.commit()
+ session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
+ return uid, login, session
+
+
+def _create_regular_session():
+ """Helper: create a regular user and return (user_id, login, session_cookie)."""
+ from app.db import get_conn
+ from app.auth.session import SessionManager
+ with get_conn() as conn:
+ import secrets
+ login = f"reguser_{secrets.token_hex(4)}"
+ conn.execute(
+ "INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'Regular', ?, 0)",
+ (login, f"{login}@test.com"),
+ )
+ uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
+ conn.commit()
+ session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0})
+ return uid, login, session
+
+
+def test_admin_list_users_unauthorized(client):
+ """GET /api/admin/users — 403 without admin session."""
+ resp = client.get("/api/admin/users")
+ assert resp.status_code == 403
+
+
+def test_admin_list_users_authorized(client):
+ """GET /api/admin/users — 200 with admin session."""
+ uid, login, session = _create_admin_session()
+ resp = client.get("/api/admin/users", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "users" in data
+ assert any(u["login"] == login for u in data["users"])
+ # cleanup
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_admin_create_user(client):
+ """POST /api/admin/users — create user as admin."""
+ uid, login, session = _create_admin_session()
+ resp = client.post(
+ "/api/admin/users",
+ json={"login": "newuser99", "name": "New User", "email": "new@test.com", "password": "secret123"},
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+ new_id = resp.json()["user"]["id"]
+ # cleanup
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id IN (?, ?)", (uid, new_id))
+ conn.commit()
+
+
+def test_admin_create_user_missing_fields(client):
+ """POST /api/admin/users — 400 without required fields."""
+ uid, login, session = _create_admin_session()
+ resp = client.post(
+ "/api/admin/users",
+ json={"login": "baduser"},
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code == 400
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_admin_update_user(client):
+ """PUT /api/admin/users/{id} — update user details."""
+ uid, login, session = _create_admin_session()
+ # Create a target user first
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('toupdate', 'Old Name', 'old@t.com')")
+ target_id = conn.execute("SELECT id FROM users WHERE login='toupdate'").fetchone()["id"]
+ conn.commit()
+ resp = client.put(
+ f"/api/admin/users/{target_id}",
+ json={"name": "Updated Name", "is_active": 1},
+ cookies={"flowdeck_session": session},
+ )
+ assert resp.status_code == 200
+ with get_conn() as conn:
+ updated = conn.execute("SELECT full_name FROM users WHERE id=?", (target_id,)).fetchone()
+ assert updated["full_name"] == "Updated Name"
+ conn.execute("DELETE FROM users WHERE id IN (?, ?)", (uid, target_id))
+ conn.commit()
+
+
+def test_admin_delete_user_not_found(client):
+ """DELETE /api/admin/users/99999 — 404 for nonexistent user."""
+ uid, login, session = _create_admin_session()
+ resp = client.delete("/api/admin/users/99999", cookies={"flowdeck_session": session})
+ assert resp.status_code == 404
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_admin_delete_user_unauthorized(client):
+ """DELETE /api/admin/users/{id} — 403 for non-admin."""
+ uid, login, session = _create_regular_session()
+ resp = client.delete(f"/api/admin/users/{uid}", cookies={"flowdeck_session": session})
+ assert resp.status_code == 403
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_admin_delete_user_simple(client):
+ """DELETE /api/admin/users/{id} — delete a user with no associated data."""
+ # Create admin
+ admin_id, admin_login, admin_session = _create_admin_session()
+ # Create target user to delete
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('todelete', 'Delete Me', 'del@t.com')")
+ target_id = conn.execute("SELECT id FROM users WHERE login='todelete'").fetchone()["id"]
+ conn.commit()
+ resp = client.delete(f"/api/admin/users/{target_id}", cookies={"flowdeck_session": admin_session})
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+ # Verify user is deleted
+ with get_conn() as conn:
+ row = conn.execute("SELECT id FROM users WHERE id=?", (target_id,)).fetchone()
+ assert row is None
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (admin_id,))
+ conn.execute("DELETE FROM users WHERE id=?", (admin_id,))
+ conn.commit()
+
+
+def test_admin_delete_user_cascade(client):
+ """DELETE /api/admin/users/{id} — cascade delete all associated data.
+
+ Creates a user with: OAuth tokens, Gitea private pages, tags, comments,
+ workspace membership, login history. Verifies all are cleaned up.
+ """
+ from app.db import get_conn
+ admin_id, admin_login, admin_session = _create_admin_session()
+ # Create target user
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('cascade_me', 'Cascade', 'cas@t.com')")
+ target_id = conn.execute("SELECT id FROM users WHERE login='cascade_me'").fetchone()["id"]
+ # Add OAuth token
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'cascade-tok')",
+ (target_id,),
+ )
+ # Add Gitea private page
+ conn.execute(
+ "INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?, 'o', 'r', 'Page')",
+ (target_id,),
+ )
+ # Add tag
+ conn.execute("INSERT INTO tags (name, color, user_id) VALUES ('mytag', '#fff', ?)", (target_id,))
+ # Add login history
+ conn.execute("INSERT INTO login_history (user_id, ip_address) VALUES (?, '127.0.0.1')", (target_id,))
+ # Create workspace owned by user
+ conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('My WS', ?)", (target_id,))
+ ws_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO workspace_members (workspace_id, user_id) VALUES (?, ?)", (ws_id, target_id))
+ # Create collection and page for comment (need FK to collection)
+ conn.execute("INSERT INTO collections (name) VALUES ('cascade_col')")
+ col_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO collection_pages (collection_id, title) VALUES (?, 'cp')", (col_id,))
+ cp_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO comments (page_id, user_id, body) VALUES (?, ?, 'hello')", (cp_id, target_id))
+ conn.commit()
+ # Delete user (cascade)
+ resp = client.delete(f"/api/admin/users/{target_id}", cookies={"flowdeck_session": admin_session})
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+ # Verify all related data is gone
+ with get_conn() as conn:
+ assert conn.execute("SELECT id FROM users WHERE id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM user_oauth_tokens WHERE user_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM gitea_private_pages WHERE user_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM tags WHERE user_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM login_history WHERE user_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM workspace_members WHERE user_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM comments WHERE user_id=?", (target_id,)).fetchone() is None
+ # Cleanup orphaned collection_pages and collection
+ conn.execute("DELETE FROM collection_pages WHERE id=?", (cp_id,))
+ conn.execute("DELETE FROM collections WHERE id=?", (col_id,))
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (admin_id,))
+ conn.execute("DELETE FROM users WHERE id=?", (admin_id,))
+ conn.commit()
+
+
+def test_admin_delete_user_cascade_with_pages(client):
+ """DELETE admin user cascade — also deletes workspace pages and favorites."""
+ from app.db import get_conn
+ admin_id, admin_login, admin_session = _create_admin_session()
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('caspage', 'CascadePage', 'cp@t.com')")
+ target_id = conn.execute("SELECT id FROM users WHERE login='caspage'").fetchone()["id"]
+ # Create workspace with pages
+ conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('PageWS', ?)", (target_id,))
+ ws_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO pages (workspace, workspace_id, title) VALUES ('w', ?, 'Page1')", (ws_id,))
+ page_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (target_id, page_id))
+ # Create collection and page for page_history FK chain
+ conn.execute("INSERT INTO collections (name) VALUES ('cascade_col2')")
+ col_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO collection_pages (collection_id, title) VALUES (?, 'cp2')", (col_id,))
+ cp_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute("INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?, ?, 'edited', '{}')", (cp_id, target_id))
+ conn.commit()
+ resp = client.delete(f"/api/admin/users/{target_id}", cookies={"flowdeck_session": admin_session})
+ assert resp.status_code == 200
+ with get_conn() as conn:
+ assert conn.execute("SELECT id FROM users WHERE id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM favorites WHERE user_id=?", (target_id,)).fetchone() is None
+ assert conn.execute("SELECT id FROM page_history WHERE user_id=?", (target_id,)).fetchone() is None
+ # Cleanup orphaned collection_pages and collection
+ conn.execute("DELETE FROM collection_pages WHERE id=?", (cp_id,))
+ conn.execute("DELETE FROM collections WHERE id=?", (col_id,))
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (admin_id,))
+ conn.execute("DELETE FROM users WHERE id=?", (admin_id,))
+ conn.commit()
+
+
+def test_admin_stats(client):
+ """GET /api/admin/stats — admin can see aggregate statistics."""
+ uid, login, session = _create_admin_session()
+ resp = client.get("/api/admin/stats", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ data = resp.json()
+ for key in ("total_users", "total_workspaces", "total_files"):
+ assert key in data
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_admin_stats_unauthorized(client):
+ """GET /api/admin/stats — 403 for non-admin."""
+ resp = client.get("/api/admin/stats")
+ assert resp.status_code == 403
+
+
+def test_admin_audit(client):
+ """GET /api/admin/audit — admin can view login history."""
+ uid, login, session = _create_admin_session()
+ resp = client.get("/api/admin/audit", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ assert "entries" in resp.json()
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: Gitea Status ──
+# ══════════════════════════════════════════════════════
+
+def test_gitea_status_unlinked(client):
+ """GET /api/gitea/status — returns linked=false when no session/token."""
+ resp = client.get("/api/gitea/status")
+ assert resp.status_code == 200
+ assert resp.json()["linked"] is False
+
+
+def test_gitea_status_linked(client):
+ """GET /api/gitea/status — returns linked=true when OAuth token exists."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('gstatus', 'GStatus', 'gs@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='gstatus'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'status-token')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "gstatus", "is_admin": 0})
+ resp = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ assert resp.json()["linked"] is True
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_gitea_status_with_expired_token(client):
+ """GET /api/gitea/status — returns linked=true even with old token (token existence is enough)."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('gexpired', 'GExp', 'ge@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='gexpired'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token, expires_at) VALUES (?, 'gitea', 'old-token', '2020-01-01')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "gexpired", "is_admin": 0})
+ resp = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ # Token exists → linked=true (status endpoint only checks existence)
+ assert resp.json()["linked"] is True
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_gitea_disconnect_no_auth(client):
+ """DELETE /api/gitea/disconnect — 401 without session."""
+ resp = client.delete("/api/gitea/disconnect")
+ assert resp.status_code == 401
+
+
+def test_gitea_disconnect_with_auth(client):
+ """DELETE /api/gitea/disconnect — removes OAuth tokens for authenticated user."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('disconn', 'Disconn', 'dc@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='disconn'").fetchone()["id"]
+ conn.execute(
+ "INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'dc-token')",
+ (uid,),
+ )
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "disconn", "is_admin": 0})
+ # Verify linked before
+ status_before = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
+ assert status_before.json()["linked"] is True
+ # Disconnect
+ resp = client.delete("/api/gitea/disconnect", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+ # Verify unlinked after
+ status_after = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
+ assert status_after.json()["linked"] is False
+ with get_conn() as conn:
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: OAuth Link Mode ──
+# ══════════════════════════════════════════════════════
+
+def test_oauth_login_local_page(client):
+ """GET /auth/login?provider=local — renders local login HTML page."""
+ resp = client.get("/auth/login?provider=local")
+ assert resp.status_code == 200
+ assert "FlowDeck" in resp.text
+ assert "Login" in resp.text or "login" in resp.text.lower()
+
+
+def test_oauth_login_gitea_redirect(client):
+ """GET /auth/login?provider=gitea — redirects to Gitea OAuth (configured in test env)."""
+ resp = client.get("/auth/login?provider=gitea", follow_redirects=False)
+ # Gitea OAuth IS configured in test env → redirect to Gitea
+ assert resp.status_code == 302
+ assert "login/oauth" in resp.headers.get("location", "").lower()
+
+
+def test_oauth_login_with_link_mode(client):
+ """GET /auth/login?provider=gitea&mode=link — link mode redirects to Gitea OAuth."""
+ resp = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False)
+ # Gitea OAuth IS configured → redirect to Gitea with link mode set in session
+ assert resp.status_code == 302
+ assert "login/oauth" in resp.headers.get("location", "").lower()
+
+
+def test_oauth_login_with_mode_link_and_provider_github(client):
+ """GET /auth/login?provider=github&mode=link — sets link mode for GitHub."""
+ resp = client.get("/auth/login?provider=github&mode=link")
+ # GitHub OAuth not configured either → error page
+ assert resp.status_code == 200
+ assert "github" in resp.text.lower() or "not configured" in resp.text.lower()
+
+
+def test_oauth_callback_invalid_state(client):
+ """GET /auth/callback?code=test&state=invalid — 400 for invalid state."""
+ resp = client.get("/auth/callback?code=test_code&state=invalid_state")
+ assert resp.status_code == 400
+
+
+def test_oauth_callback_missing_code(client):
+ """GET /auth/callback — 422 without required code param."""
+ resp = client.get("/auth/callback")
+ assert resp.status_code == 422
+
+
+def test_oauth_logout(client):
+ """GET /auth/logout — redirects to local login page (follow_redirects=False)."""
+ resp = client.get("/auth/logout", follow_redirects=False)
+ assert resp.status_code == 302
+ assert "login" in resp.headers.get("location", "").lower()
+
+
+def test_auth_register_missing_fields(client):
+ """POST /auth/register — 400 without email/password."""
+ resp = client.post("/auth/register", json={})
+ assert resp.status_code == 400
+
+
+def test_auth_register_short_password(client):
+ """POST /auth/register — 400 with password < 6 chars."""
+ resp = client.post("/auth/register", json={"email": "test@test.com", "password": "ab"})
+ assert resp.status_code == 400
+ assert "6" in resp.json()["error"]
+
+
+def test_auth_register_success(client):
+ """POST /auth/register — successfully register a new user."""
+ resp = client.post("/auth/register", json={"email": "new_user@test.com", "password": "secret123", "name": "New User"})
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+ assert resp.json()["user"]["login"] == "new_user@test.com"
+ # cleanup
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=(SELECT id FROM users WHERE login='new_user@test.com')")
+ conn.execute("DELETE FROM users WHERE login='new_user@test.com'")
+ conn.commit()
+
+
+def test_auth_register_duplicate(client):
+ """POST /auth/register — 409 for duplicate email."""
+ resp = client.post("/auth/register", json={"email": "dup_user@test.com", "password": "secret123"})
+ assert resp.status_code == 200
+ # Try again with same email
+ resp2 = client.post("/auth/register", json={"email": "dup_user@test.com", "password": "another1"})
+ assert resp2.status_code == 409
+ # cleanup
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("DELETE FROM login_history WHERE user_id=(SELECT id FROM users WHERE login='dup_user@test.com')")
+ conn.execute("DELETE FROM users WHERE login='dup_user@test.com'")
+ conn.commit()
+
+
+def test_auth_local_login_invalid_credentials(client):
+ """POST /auth/local-login — 401 with wrong password."""
+ resp = client.post("/auth/local-login", json={"email": "nonexistent@test.com", "password": "wrong"})
+ assert resp.status_code == 401
+
+
+def test_auth_local_login_missing_fields(client):
+ """POST /auth/local-login — 400 without email/password."""
+ resp = client.post("/auth/local-login", json={})
+ assert resp.status_code == 400
+
+
+def test_auth_user_authenticated(client):
+ """GET /auth/user — returns authenticated=true with valid session."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO users (login, full_name, email) VALUES ('authuser', 'Auth', 'au@t.com')")
+ uid = conn.execute("SELECT id FROM users WHERE login='authuser'").fetchone()["id"]
+ conn.commit()
+ from app.auth.session import SessionManager
+ session = SessionManager.create_session({"id": uid, "login": "authuser", "is_admin": 0})
+ resp = client.get("/auth/user", cookies={"flowdeck_session": session})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["authenticated"] is True
+ assert data["user"]["login"] == "authuser"
+ with get_conn() as conn:
+ conn.execute("DELETE FROM users WHERE id=?", (uid,))
+ conn.commit()
+
+
+def test_auth_user_unauthenticated(client):
+ """GET /auth/user — returns authenticated=false without session."""
+ resp = client.get("/auth/user")
+ assert resp.status_code == 200
+ assert resp.json()["authenticated"] is False
+
+
+# ══════════════════════════════════════════════════════
+# ── v3.0.0: Gitea API Edge Cases ──
+# ══════════════════════════════════════════════════════
+
+def test_gitea_labels_no_auth(client):
+ """GET /api/gitea/projects/owner/repo/labels — 401 without Gitea linked."""
+ resp = client.get("/api/gitea/projects/owner/repo/labels")
+ assert resp.status_code == 401
+
+
+def test_gitea_tree_no_auth(client):
+ """GET /api/gitea/projects/owner/repo/tree — 401 without Gitea linked."""
+ resp = client.get("/api/gitea/projects/owner/repo/tree")
+ assert resp.status_code == 401
+
+
+def test_gitea_file_get_no_auth(client):
+ """GET /api/gitea/projects/owner/repo/file?path=x — 401 without Gitea linked."""
+ resp = client.get("/api/gitea/projects/owner/repo/file?path=README.md")
+ assert resp.status_code == 401
+
+
+def test_gitea_orgs_no_auth(client):
+ """GET /api/gitea/orgs — 401 without Gitea linked."""
+ resp = client.get("/api/gitea/orgs")
+ assert resp.status_code == 401
+
+
+def test_gitea_projects_no_auth(client):
+ """GET /api/gitea/projects — 401 without Gitea linked."""
+ resp = client.get("/api/gitea/projects")
+ assert resp.status_code == 401
+
+
+def test_gitea_file_delete_no_auth(client):
+ """DELETE /api/gitea/projects/owner/repo/file — 401 without Gitea linked."""
+ resp = client.delete("/api/gitea/projects/owner/repo/file?path=test.md&sha=abc")
+ assert resp.status_code in (400, 401)
+
+
+def test_gitea_file_delete_missing_params(client):
+ """DELETE file without path+sha → 400 even without auth."""
+ resp = client.delete("/api/gitea/projects/owner/repo/file")
+ assert resp.status_code in (400, 401)
+
+
+def test_gitea_private_pages_list_no_auth(client):
+ """GET private-pages — returns empty without auth."""
+ resp = client.get("/api/gitea/projects/owner/repo/private-pages")
+ assert resp.status_code == 200
+ assert resp.json()["pages"] == []
+
+
+def test_gitea_private_pages_create_no_auth(client):
+ """POST private-pages — 401 without session."""
+ resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
+ assert resp.status_code == 401