Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8514386a9b | ||
|
|
68f7f97b0d | ||
|
|
ed465333e4 | ||
|
|
c49e152e7c | ||
|
|
8e6ed1e251 | ||
|
|
a5831456e1 |
+12
-14
@@ -177,18 +177,16 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
|
||||
## 🔜 Prochaines versions
|
||||
|
||||
### v4.0.1 — Onboarding & Polish 🎯 (priorité MAX)
|
||||
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction.
|
||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction. **COMPLETED**.
|
||||
|
||||
- [ ] **Landing page `/` pour visiteurs non-auth** — page d'accueil (pas de redirect loop)
|
||||
- [ ] **Page `/auth/register` GET** — formulaire d'inscription dédié (pas juste un tab)
|
||||
- [ ] **Redirection `/` intelligente** — si pas connecté → login, si connecté sans Gitea → local workspace
|
||||
- [ ] **Empty states cohérents** — onboarding dans le local workspace vide
|
||||
- [ ] **Page 404 stylisée** — thème Notion sombre (actuellement HTML brut)
|
||||
- [ ] **Unifier les routes API** — `/board/api/pages` ↔ `/api/pages` (cohérence)
|
||||
- [ ] **Page Settings accessible** → lien dans le menu utilisateur déjà présent, vérifier fonctionnel
|
||||
|
||||
**Effort estimé** : 2-3 sessions | **Impact** : 🔴 Critique pour adoption
|
||||
- [x] **Landing page `/` pour visiteurs non-auth** — template `landing.html`, design Notion dark
|
||||
- [x] **Page `/auth/register` GET** — formulaire d'inscription dédié (tab register actif)
|
||||
- [x] **Redirection `/` intelligente** — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
|
||||
- [x] **Empty states** — déjà présents dans local workspace (« Welcome to your Workspace »)
|
||||
- [x] **Page 404 stylisée** — thème Notion sombre, JSON pour `/api/*`, HTML pour le reste
|
||||
- [x] **Unifier les routes API** — `/api/pages` GET/POST → 307 redirect vers `/board/api/pages`
|
||||
- [x] **Page Settings** — lien déjà présent dans le menu utilisateur (Settings → `/accounts/settings`)
|
||||
|
||||
### v4.0.2 — Qualité & Robustesse
|
||||
> **Objectif** : zéro crash, zéro regression, DX impeccable.
|
||||
@@ -275,11 +273,11 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
## Résumé des phases
|
||||
|
||||
```
|
||||
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ⬜
|
||||
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ✅
|
||||
Base + Kanban Éditeur + Gitea UX Pro MVP Onboarding
|
||||
+ DB + Auth + Workspaces + Multi-select Accounts & Polish
|
||||
+ UI Notion + Tags + Admin + Sharing PRIORITÉ
|
||||
+ GitHub OAuth + Library MAX
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ⬜ v4.1.0 ⬜ v4.2.0 ⬜ v4.3.0 ⬜ v4.4–4.10 ⬜ v5.0.0 ⬜
|
||||
Quality Blocks Export Collab Notion Pro
|
||||
|
||||
@@ -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='👁';}}
|
||||
|
||||
+23
-17
@@ -791,23 +791,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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1285,7 +1285,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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=§ion=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
|
||||
|
||||
Reference in New Issue
Block a user