v3.0.1: qualité & stabilité — tests, sécurité, UX consolidée
🧪 Tests: 73 → 130 (+57 tests) - Upload API, labels sync, commit history (401/400/502/cached) - File create/update (sha=null, sha=abc) - Admin delete cascade (12 tests: oauth, tags, workspaces, pages…) - Gitea status (linked/unlinked/disconnect) - OAuth link mode (session, redirect, callback) 🔒 Sécurité - ContentSecurityPolicyMiddleware (CSP headers) - RateLimitMiddleware (100 req/min/IP) - Pydantic models: ErrorResponse, SuccessResponse - Input validation upload (10MB, allowed extensions) - Pydantic request models 🎨 UX - static/css/design-tokens.css (500 lines, thèmes + skeletons) - Toast system: 16 alert() remplacés par toast() - 59 lignes CSS dupliquées retirées (6 templates) - Skeletons cohérents sur toutes les vues ⚡ Performance - Cache TTL sur get_file_commits (consistant avec les autres méthodes)
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}}
|
||||
}
|
||||
}
|
||||
+47
-21
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=2.4.6">
|
||||
<script src="https://unpkg.com/[email protected]"></script>
|
||||
<script src="https://unpkg.com/[email protected]" defer></script>
|
||||
@@ -333,11 +334,6 @@
|
||||
<!-- Uncollapse button → inside topbar, no overlap -->
|
||||
<!-- (Moved into topbar block below) -->
|
||||
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- ═══════════ MAIN ═══════════ -->
|
||||
<div class="main-wrapper">
|
||||
|
||||
@@ -497,6 +493,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ TOAST NOTIFICATIONS ============ -->
|
||||
<div class="toast-container">
|
||||
<template x-for="t in toasts" :key="t.id">
|
||||
<div class="toast" :class="'toast-' + t.type"
|
||||
x-show="true"
|
||||
x-transition:enter=""
|
||||
x-transition:leave=""
|
||||
x-text="t.msg"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -517,6 +524,17 @@
|
||||
sidebarCollapsed: false,
|
||||
mobileSidebarOpen: false,
|
||||
showInvite: true,
|
||||
// -- Toast notifications --
|
||||
toasts: [],
|
||||
toast(msg, type) {
|
||||
type = type || 'info';
|
||||
var id = Date.now() + Math.random();
|
||||
this.toasts.push({ id: id, msg: msg, type: type });
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
|
||||
}, 3000);
|
||||
},
|
||||
wsHeaderHover: false,
|
||||
workspaceName: '{{ workspace_name }}',
|
||||
workspaceInitial: '{{ workspace_initial }}',
|
||||
@@ -620,7 +638,7 @@
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { alert('Quick Find — Ctrl+K'); },
|
||||
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
|
||||
|
||||
// ── Tree state ──
|
||||
expandedNodes: {},
|
||||
@@ -725,11 +743,11 @@
|
||||
// Reload to update sidebar
|
||||
location.reload();
|
||||
})
|
||||
.catch(e => alert('Favorite action failed'));
|
||||
.catch(e => this.toast('Favorite action failed', 'error'));
|
||||
break;
|
||||
case 'copyLink':
|
||||
const url = window.location.origin + '/' + pageId;
|
||||
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
||||
navigator.clipboard.writeText(url).then(() => this.toast('Link copied!', 'success'));
|
||||
break;
|
||||
case 'duplicate':
|
||||
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}§ion=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
|
||||
@@ -737,7 +755,7 @@
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(e => alert('Duplicate failed'));
|
||||
.catch(e => this.toast('Duplicate failed', 'error'));
|
||||
break;
|
||||
case 'rename':
|
||||
const newName = prompt('New name:', pageName);
|
||||
@@ -750,7 +768,7 @@
|
||||
case 'moveTo':
|
||||
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
|
||||
if (!section) break;
|
||||
alert(`Moved to ${section}`);
|
||||
this.toast('Moved to ' + section, 'success');
|
||||
break;
|
||||
case 'delete':
|
||||
if (!confirm(`Move "${pageName}" to trash?`)) break;
|
||||
@@ -775,13 +793,13 @@
|
||||
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Delete failed: ' + e.message));
|
||||
.catch(e => this.toast('Delete failed: ' + e.message, 'error'));
|
||||
break;
|
||||
case 'openTab':
|
||||
window.open('/' + pageId, '_blank');
|
||||
break;
|
||||
case 'openPeek':
|
||||
alert('Side peek not available in browser');
|
||||
this.toast('Side peek not available in browser', 'info');
|
||||
break;
|
||||
}
|
||||
this.contextMenu.visible = false;
|
||||
@@ -806,7 +824,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed to create page: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed to create page: ' + err.message, 'error'); });
|
||||
},
|
||||
newSubPage(parentId) {
|
||||
const project = this.workspaceKey || '';
|
||||
@@ -817,7 +835,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed to create sub-page: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
|
||||
},
|
||||
newPageInWorkspace() {
|
||||
const name = prompt('File name:');
|
||||
@@ -829,7 +847,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
newFolderInWorkspace() {
|
||||
const name = prompt('Folder name:');
|
||||
@@ -841,7 +859,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(() => { window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
deleteWorkspacePage(pageId) {
|
||||
if (!confirm('Delete this page?')) return;
|
||||
@@ -851,7 +869,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(() => { window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
|
||||
// ── Drag & Drop ──
|
||||
@@ -880,7 +898,7 @@
|
||||
body: JSON.stringify({ parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||
.catch(err => { alert('Move failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Move failed: ' + err.message, 'error'); });
|
||||
},
|
||||
|
||||
// ── Sub-item creation in folders ──
|
||||
@@ -894,7 +912,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
newFolderInFolder(parentId) {
|
||||
const name = prompt('Sub-folder name:');
|
||||
@@ -905,7 +923,7 @@
|
||||
body: JSON.stringify({ name: name.trim(), type: 'folder', parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
openWorkspacePage(pageId) {
|
||||
window.location = `/pages/${pageId}`;
|
||||
@@ -937,7 +955,7 @@
|
||||
},
|
||||
createDatabase() {
|
||||
this.showNewPageMenu = false;
|
||||
alert('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.');
|
||||
this.toast('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.', 'info');
|
||||
},
|
||||
createBoard() {
|
||||
this.showNewPageMenu = false;
|
||||
@@ -1030,6 +1048,14 @@
|
||||
if (e.persisted) applySavedTheme();
|
||||
});
|
||||
|
||||
// -- Global toast helper (accessible from any component) --
|
||||
window.showToast = function(msg, type) {
|
||||
var el = document.querySelector('[x-data]');
|
||||
if (el && el.__x && el.__x.$data && el.__x.$data.toast) {
|
||||
el.__x.$data.toast(msg, type || 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════ GLOBAL KEYBOARD SHORTCUTS ═══════════
|
||||
document.addEventListener('keydown', function(e) {
|
||||
var tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||
|
||||
@@ -42,17 +42,6 @@
|
||||
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
||||
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
||||
.gw-commit-bar input:focus{border-color:var(--accent);}
|
||||
.btn-primary{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;background:var(--accent);color:#fff;}
|
||||
.btn-primary:hover{background:var(--accent-hover);}
|
||||
.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;}
|
||||
.btn-danger:hover{background:rgba(255,80,80,.25);}
|
||||
.btn{border:none;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);}
|
||||
.btn:hover{background:var(--bg-tertiary);color:var(--text);}
|
||||
|
||||
/* ── 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}}
|
||||
.file-skeleton{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);max-height:70vh;overflow:hidden;}
|
||||
|
||||
/* Prism theme tweaks for dark UI */
|
||||
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||
@@ -70,7 +59,7 @@
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<!-- Skeleton loading -->
|
||||
<div x-show="fileLoading" class="file-skeleton">
|
||||
<div x-show="fileLoading" class="skeleton-file">
|
||||
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
|
||||
@@ -450,9 +439,9 @@ function giteaWorkspace() {
|
||||
}
|
||||
} else {
|
||||
var d = await r.json();
|
||||
alert('Failed to save: ' + (d.error || 'Unknown error'));
|
||||
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { alert('Network error'); }
|
||||
} catch(e) { window.showToast('Network error', 'error'); }
|
||||
},
|
||||
|
||||
async deleteCurrentFile() {
|
||||
@@ -551,9 +540,9 @@ function giteaWorkspace() {
|
||||
await this.loadSidebarTree();
|
||||
} else {
|
||||
var d = await r.json();
|
||||
alert('Failed: ' + (d.error || d.detail || 'Unknown error'));
|
||||
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { alert('Error: ' + e.message); }
|
||||
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// Upload files
|
||||
@@ -720,7 +709,7 @@ function giteaWorkspace() {
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
alert('Rename failed');
|
||||
window.showToast('Rename failed', 'error');
|
||||
}
|
||||
}).catch(function() {
|
||||
var span = document.createElement('span');
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}}
|
||||
</style>
|
||||
|
||||
<div class="ws-page" x-data="workspacesPage()">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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": "[email protected]", "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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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', '[email protected]')")
|
||||
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": "[email protected]", "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": "[email protected]", "password": "secret123", "name": "New User"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
assert resp.json()["user"]["login"] == "[email protected]"
|
||||
# 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='[email protected]')")
|
||||
conn.execute("DELETE FROM users WHERE login='[email protected]'")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_auth_register_duplicate(client):
|
||||
"""POST /auth/register — 409 for duplicate email."""
|
||||
resp = client.post("/auth/register", json={"email": "[email protected]", "password": "secret123"})
|
||||
assert resp.status_code == 200
|
||||
# Try again with same email
|
||||
resp2 = client.post("/auth/register", json={"email": "[email protected]", "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='[email protected]')")
|
||||
conn.execute("DELETE FROM users WHERE login='[email protected]'")
|
||||
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": "[email protected]", "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', '[email protected]')")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user