Compare commits

...
Author SHA1 Message Date
bruno b2486a6e11 fix: sidebar workspace section — hide create buttons quand aucun workspace actif
- Ajout flag has_active_workspace dans _sidebar_data (dashboard.py + board.py)
- Template base.html: quand has_active_workspace=False:
  - Titre section → '📁 No workspace open'
  - Boutons New File/New Folder cachés
  - Message 'Open a workspace to see your files'
- Guard JS newPageInWorkspace()/newFolderInWorkspace(): toast + redirect si pas de workspace
- 143 tests passent
2026-07-20 08:59:00 -04:00
bruno 68f7f97b0d merge: feat/quality → develop (v4.0.2) 2026-07-20 08:07:28 -04:00
bruno ed465333e4 feat: v4.0.2 — qualité & robustesse (session expiry UX, validation, mobile, tests)
- Session expiry: redirects ajoutent ?expired=1 → bannière "Session expired" sur login
- Page titles: titre vide → 'Untitled' par défaut (au lieu de chaîne vide)
- Error handling: try/catch dans create_page avec message user-friendly (500)
- Mobile responsive: sidebar slide-in, modales centrées, landing page adaptative
- 10 nouveaux tests de non-régression: landing, register, 404, validation, duplicate
- 143 tests passent (133 + 10)
2026-07-20 08:07:22 -04:00
5 changed files with 239 additions and 24 deletions
+3
View File
@@ -55,6 +55,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
<button class="tab active" onclick="switchTab('login')" id="tab-login">Login</button>
<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>
</div>
<div id="expired-msg" class="success" style="display:none">⚠️ Your session has expired. Please log in again.</div>
<div id="error-msg" class="error"></div>
<div id="success-msg" class="success"></div>
<form id="login-form" onsubmit="handleLogin(event)">
@@ -76,6 +77,8 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
</div>
</div>
<script>
// Show session expired banner if ?expired=1 in URL
(function(){if(location.search.includes('expired=1')){var el=document.getElementById('expired-msg');if(el)el.style.display='block';}})();
let mode='login';
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
+28 -18
View File
@@ -229,6 +229,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: preserve context across pages
@@ -238,6 +239,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
workspace_pages = [] # loaded client-side
# Get local workspace ID for mirror
if user:
@@ -262,6 +264,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
except (ValueError, Exception):
pass
local_ws_id = 0
@@ -376,7 +379,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"user": user,
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked}
"github_linked": github_linked,
"has_active_workspace": has_active_workspace}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -791,23 +795,29 @@ async def create_page(request: Request, title: str = Query(default=""),
"""Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
with get_conn() as conn:
# Compute next sort_order for this parent
next_order = 0
parent_val = parent_id if parent_id > 0 else None
row = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
(ws_key, parent_val),
).fetchone()
if row:
next_order = row[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
(ws_key, title, section, parent_val, next_order),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": title, "workspace": ws_key, "parent_id": parent_id}
page_title = title.strip() if title else "Untitled"
try:
with get_conn() as conn:
# Compute next sort_order for this parent
next_order = 0
parent_val = parent_id if parent_id > 0 else None
row = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
(ws_key, parent_val),
).fetchone()
if row:
next_order = row[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
(ws_key, page_title, section, parent_val, next_order),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e:
logger.error("create_page failed: %s", e)
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
@router.get("/api/pages/{page_id}")
+7 -3
View File
@@ -27,7 +27,7 @@ def _get_user_or_redirect(request: Request):
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception:
pass
return RedirectResponse("/auth/login?provider=local", status_code=302)
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user
@@ -73,8 +73,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
if include_workspace and ws_cookie and ws_cookie.startswith("gitea:"):
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading
# BUT keep local workspace pages — they go in the "Private" section
parts = ws_cookie.split(":", 2)
@@ -83,6 +84,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# workspace_pages stays as-is (local tree, loaded above)
elif include_workspace and ws_cookie and user:
try:
@@ -96,6 +98,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# else: stale cookie from another user — ignore
except (ValueError, Exception):
pass
@@ -169,6 +172,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
}
@@ -1285,7 +1289,7 @@ async def workspaces_page(request: Request):
sidebar = _sidebar_data(request, [], include_workspace=False)
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
ws = _get_active_workspace(request, user_id=_get_user_id(request))
+112 -3
View File
@@ -6,6 +6,89 @@
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css?v=3.0.0">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
width: 280px;
transform: translateX(-100%);
transition: transform .2s ease;
}
.sidebar.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0,0,0,.5);
}
.sidebar-overlay.visible {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
z-index: 99;
}
.main-wrapper {
margin-left: 0 !important;
width: 100%;
}
.unified-header {
padding: 8px 12px;
}
.page-editor-wrapper {
padding: 16px 8px;
}
.page-title-block {
padding: 16px 8px;
}
.blocks-container {
padding: 0 4px;
}
.share-dialog, .more-menu, .activity-popover {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: 92vw;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
}
.landing-nav {
padding: 12px 16px;
}
.hero {
padding: 40px 16px 30px;
}
.hero h1 {
font-size: 1.8rem;
}
.features {
grid-template-columns: 1fr;
padding: 0 16px 30px;
}
.library-card-grid {
grid-template-columns: 1fr;
}
.view-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
}
@media (max-width: 480px) {
.hero h1 {
font-size: 1.5rem;
}
.hero p {
font-size: 15px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
}
}
</style>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
<script src="/static/js/sortable.min.js" defer></script>
@@ -119,20 +202,30 @@
<div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
{% if has_active_workspace %}
<span>{% if gitea_workspace %}📁 {{ active_ws_name }}{% elif workspace_key and '/' in workspace_key %}🔗{% else %}📁{% endif %} {{ active_ws_name if not gitea_workspace else '' }}</span>
{% else %}
<span>📁 No workspace open</span>
{% endif %}
</div>
{% if has_active_workspace %}
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">📄</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">📁</button>
</div>
{% endif %}
</div>
{% from '_workspace_tree_macro.html' import render_workspace_tree %}
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
<ul class="sidebar-items" data-section="workspace" id="sidebar-workspace-items">
{% if workspace_pages %}
{{ render_workspace_tree(workspace_pages) }}
{% if has_active_workspace %}
{% if workspace_pages %}
{{ render_workspace_tree(workspace_pages) }}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
<li class="sidebar-item empty-hint"><span class="page-icon">📁</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
{% endif %}
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
<span class="page-icon">⚙</span><span class="page-name text-dim">Manage Workspaces</span>
@@ -1039,6 +1132,14 @@
});
},
newPageInWorkspace() {
// Guard: no workspace active — redirect to workspaces page
if (!this.workspaceKey || this.workspaceKey === this.workspaceName) {
if (!this.giteaWorkspace && !this.localWsId) {
this.toast('No workspace open — create one first', 'info');
setTimeout(() => { window.location.href = '/workspaces'; }, 1200);
return;
}
}
const name = prompt('File name:');
if (!name || !name.trim()) return;
const wk = this.workspaceKey || '';
@@ -1066,6 +1167,14 @@
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
newFolderInWorkspace() {
// Guard: no workspace active — redirect to workspaces page
if (!this.workspaceKey || this.workspaceKey === this.workspaceName) {
if (!this.giteaWorkspace && !this.localWsId) {
this.toast('No workspace open — create one first', 'info');
setTimeout(() => { window.location.href = '/workspaces'; }, 1200);
return;
}
}
const name = prompt('Folder name:');
if (!name || !name.trim()) return;
const wk = this.workspaceKey || '';
+89
View File
@@ -1914,3 +1914,92 @@ def test_page_renders_breadcrumb_data(client):
assert 'id="fd-breadcrumb-data"' in resp.text
assert '"Home"' in resp.text
assert "fdBreadcrumb" in resp.text
# ═══════════ v4.0.2 — Regression tests (critical paths) ═══════════
def test_landing_page_no_auth(client):
"""Visiting / without auth shows the landing page."""
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 200
assert "FlowDeck" in resp.text
assert "Get started free" in resp.text or "Get started" in resp.text
def test_register_page_get(client):
"""GET /auth/register shows the registration form."""
resp = client.get("/auth/register", follow_redirects=False)
assert resp.status_code == 200
assert "register" in resp.text.lower()
def test_login_page_shows_expired_banner(client):
"""Login page with ?expired=1 shows session expired message."""
resp = client.get("/auth/login?provider=local&expired=1", follow_redirects=False)
assert resp.status_code == 200
assert "expired" in resp.text.lower()
def test_create_page_defaults_to_untitled(client):
"""Creating a page with empty title defaults to 'Untitled'."""
resp = client.post("/board/api/pages?title=&section=Private", follow_redirects=False)
assert resp.status_code == 200
data = resp.json()
assert data["title"] == "Untitled"
def test_styled_404_page(client):
"""Unknown routes return a styled 404 HTML page."""
resp = client.get("/this-does-not-exist-xyz", follow_redirects=False)
assert resp.status_code == 404
assert "404" in resp.text
assert "FlowDeck" in resp.text
def test_api_404_returns_json(client):
"""Unknown API routes return JSON, not HTML."""
resp = client.get("/api/does-not-exist", follow_redirects=False)
assert resp.status_code == 404
data = resp.json()
assert "detail" in data
def test_login_validation_empty_fields(client):
"""Login with empty fields returns 400 error."""
resp = client.post("/auth/local-login", json={"email": "", "password": ""})
assert resp.status_code == 400
data = resp.json()
assert "error" in data
def test_register_validation_short_password(client):
"""Registration with short password returns 400."""
resp = client.post("/auth/register", json={
"email": "[email protected]", "password": "ab", "name": "Test"
})
assert resp.status_code == 400
def test_register_duplicate_rejected(client):
"""Duplicate registration returns 409."""
# Register first time
r1 = client.post("/auth/register", json={
"email": "duptest2", "password": "password123", "name": "Dup"
})
assert r1.status_code == 200
# Second registration with same email should fail
r2 = client.post("/auth/register", json={
"email": "duptest2", "password": "password123", "name": "Dup2"
})
assert r2.status_code == 409
assert "already exists" in r2.json().get("error", "").lower()
def test_session_expired_redirect(client):
"""Unauthenticated access to protected page redirects with expired param."""
resp = client.get("/workspaces", follow_redirects=False)
assert resp.status_code == 302
location = resp.headers.get("location", "")
assert "login" in location
assert "expired=1" in location