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 @@ FlowDeck + @@ -333,11 +334,6 @@ - - -
@@ -497,6 +493,17 @@
+ +
+ +
+ {% endblock %} diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index 8ac4945..8f307ac 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -152,10 +152,6 @@ /* Inline rename */ .inline-rename-input{background:var(--bg);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;padding:2px 6px;outline:none;min-width:100px;} -/* Loading skeleton */ -.skeleton{background:linear-gradient(90deg,var(--bg-tertiary) 25%,var(--bg-hover) 50%,var(--bg-tertiary) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:4px;height:16px;margin:4px 0;} -@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} - /* Drag count badge */ .drag-badge{position:fixed;pointer-events:none;z-index:999;background:var(--accent);color:#fff;font-size:12px;font-weight:700;padding:2px 8px;border-radius:10px;transform:translate(-50%,-50%);} @@ -1267,7 +1263,7 @@ window._wsInitData = (function() { if (targetId) { window.location = '/local-workspace?folder=' + targetId; } else { - alert('Path not found: ' + parts.join('/')); + window.showToast('Path not found: ' + parts.join('/'), 'error'); } }, _findFolderByPath(nodes, parts, idx) { @@ -1807,10 +1803,10 @@ window._wsInitData = (function() { if (r.ok) { this._reloadAfterAction(); } else { - alert('Move failed'); + window.showToast('Move failed', 'error'); } } catch(e) { - alert('Move failed: ' + e.message); + window.showToast('Move failed: ' + e.message, 'error'); } }, diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index 7456701..2d40c4e 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -660,9 +660,9 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';}, removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);}, exportPage(){this.moreOpen=false;}, - duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{alert('Duplicate failed');});}, + duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});}, movePage(){this.moreOpen=false;}, - deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{alert('Failed');});}, + deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});}, onKd(e){ const ab=this.getActiveBlock();if(!ab)return; diff --git a/app/templates/settings.html b/app/templates/settings.html index e0756af..545c2a0 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -57,15 +57,6 @@ .color-swatch:hover{transform:scale(1.15);} .color-swatch.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);} -/* Modal (for tag delete/rename) */ -.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:#fff;} -.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%;} -.settings-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 2px rgba(35,131,226,.25);} -.btn-danger{background:#E03E3E;color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;} -.btn-danger:hover{background:#C93535;} - /* Admin tables */ .table-wrap{overflow-x:auto;border-radius:8px;border:1px solid #333;} .admin-table{width:100%;border-collapse:collapse;font-size:13px;} @@ -73,7 +64,7 @@ .admin-table td{padding:8px 12px;border-top:1px solid var(--border);color:var(--text);} .admin-table tr:hover td{background:var(--bg-secondary);} .stat-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center;} -.stat-value{font-size:22px;font-weight:700;color:var(--accent,#2383E2);} +.stat-value{font-size:22px;font-weight:700;color:var(--accent);} .stat-label{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.5px;} .role-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;} .role-admin{background:rgba(35,131,226,.2);color:#4DA3F5;} @@ -81,15 +72,10 @@ .status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:middle;} .status-dot.active{background:#0F7B6C;} .status-dot.inactive{background:#E03E3E;} -.btn-sm{background:transparent;border:1px solid var(--border);color:var(--text-dim);padding:2px 6px;border-radius:4px;cursor:pointer;font-size:12px;} -.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);} -.btn-sm-danger{color:#E03E3E;border-color:#542;} /* Close button */ .settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;} .settings-close:hover{background:var(--bg-hover);color:var(--text);} -.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;} -.settings-select:focus{border-color:var(--accent);}
@@ -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