Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
Clear filters), toasts sur chaque action.
Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
/board/api/pages/{id}/trash (inexistante) alors que la route réelle est
/api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
réécrivait parent_section='Trash' et la restauration ne le remettait pas,
alors que tous les listings filtrent parent_section != 'Trash'. Écriture
retirée (deleted_at = source de vérité unique) + réparation idempotente au
boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
POST /api/pages/{id}/trash — vérifié anonyme → 401.
Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
487 lines
23 KiB
Python
487 lines
23 KiB
Python
"""FlowDeck — Dashboard : pages_html.
|
|
|
|
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Query, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["dashboard"])
|
|
|
|
|
|
|
|
|
|
@router.get("/trash", response_class=HTMLResponse)
|
|
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
|
"""Trash page — scoped to workspace if owner/repo provided."""
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
with get_conn() as conn:
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
|
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
|
# touché par le trash → source de vérité unique, v7.45.5)
|
|
if ws_key:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
|
(ws_key,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
|
).fetchall()
|
|
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
|
template = env.get_template("trash.html")
|
|
return template.render(**sidebar)
|
|
|
|
|
|
|
|
|
|
@router.get("/library", response_class=HTMLResponse)
|
|
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
|
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
|
|
|
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
|
"""
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
|
if ws_key_ws:
|
|
with get_conn() as conn:
|
|
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
|
|
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
|
|
else:
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
|
|
|
template = env.get_template("library.html")
|
|
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
|
# Gitea workspace context for Repository tab
|
|
sidebar["is_gitea_workspace"] = bool(owner and repo)
|
|
sidebar["gitea_workspace_owner"] = owner
|
|
sidebar["gitea_workspace_repo"] = repo
|
|
return template.render(**sidebar)
|
|
|
|
|
|
|
|
|
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
|
def view_page_root(request: Request, page_id: int):
|
|
"""Render a Markdown page at root level with workspace context — or file viewer.
|
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
|
embed = request.query_params.get("embed") == "1"
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
from app.templating import ENV
|
|
env = ENV
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
return RedirectResponse("/workspaces", status_code=302)
|
|
page = dict(row)
|
|
# v6.5.0: synced blocks resolve server-side at read time.
|
|
from app.services.synced_blocks import resolve_content_json
|
|
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
|
|
|
ws = page.get("workspace", "")
|
|
parts = ws.split("/") if "/" in ws else ["", ""]
|
|
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
# Load sub-pages
|
|
with get_conn() as conn:
|
|
subs = conn.execute(
|
|
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
|
(page_id,),
|
|
).fetchall()
|
|
fav = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
|
|
(1, page_id),
|
|
).fetchone()
|
|
|
|
# Build page_data, including file metadata for uploaded files
|
|
_locked = bool(page.get("is_locked", 0))
|
|
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
|
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
|
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
|
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
|
"is_locked": _locked,
|
|
"locked_by": _locked_by,
|
|
"can_edit": _can_edit,
|
|
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
|
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
|
|
|
# For file pages, extract file metadata and add to page_data
|
|
if page.get("content_format") == "file":
|
|
import json as _json
|
|
try:
|
|
meta = _json.loads(page.get("content", "{}"))
|
|
except _json.JSONDecodeError:
|
|
meta = {}
|
|
file_path = meta.get("file_path", "").replace("\\", "/")
|
|
mime_type = meta.get("mime_type", "application/octet-stream")
|
|
file_size = meta.get("size", 0)
|
|
fp_parts = file_path.split("/")
|
|
ws_id = ""
|
|
for p in fp_parts:
|
|
if p.startswith("workspace_"):
|
|
ws_id = p.replace("workspace_", "")
|
|
break
|
|
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
|
from urllib.parse import quote
|
|
safe_name = quote(filename, safe='')
|
|
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
|
|
page_data["file_url"] = file_url
|
|
page_data["file_mime"] = mime_type
|
|
page_data["file_size"] = file_size
|
|
page_data["file_name"] = filename
|
|
|
|
# For collection (database) pages, load collection + properties + pages
|
|
collection_data = None
|
|
if page.get("content_format") == "collection" and page.get("collection_id"):
|
|
with get_conn() as conn:
|
|
col = conn.execute(
|
|
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
|
|
).fetchone()
|
|
if col:
|
|
props = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(page["collection_id"],),
|
|
).fetchall()
|
|
]
|
|
cpages = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
|
(page["collection_id"],),
|
|
).fetchall()
|
|
]
|
|
cviews = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
|
(page["collection_id"],),
|
|
).fetchall()
|
|
]
|
|
collection_data = {
|
|
"collection": dict(col),
|
|
"properties": props,
|
|
"pages": cpages,
|
|
"views": cviews,
|
|
}
|
|
page_data["collection_id"] = page["collection_id"]
|
|
|
|
with get_conn() as conn:
|
|
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
|
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
|
page_is_shared = (
|
|
bool(page.get("is_shared", 0))
|
|
or page.get("share_mode", "private") != "private"
|
|
or bool(page.get("published", 0))
|
|
)
|
|
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
|
"page_favorited": fav is not None,
|
|
"page_share_mode": page.get("share_mode", "private"),
|
|
"page_published": bool(page.get("published", 0)),
|
|
"page_is_shared": page_is_shared,
|
|
"page_data": page_data,
|
|
"collection_data": collection_data,
|
|
"breadcrumb_items": nav_crumbs,
|
|
"nav_workspace_id": page.get("workspace_id") or 0,
|
|
"nav_page_id": page_id,
|
|
"embed_mode": embed}
|
|
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
|
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
|
# ctx ci-dessus.
|
|
from app.templating import ENV as _ENV27
|
|
page_data.update(
|
|
updated_at=page.get("updated_at", ""),
|
|
created_at=page.get("created_at", ""),
|
|
user_id=_uid or 0,
|
|
is_shared=page_is_shared,
|
|
clip_icon=_ENV27.from_string(
|
|
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
|
).render(),
|
|
)
|
|
# Select template: collection pages use database table view
|
|
if page.get("content_format") == "collection" and not embed:
|
|
template = env.get_template("page_editor_collection.html")
|
|
else:
|
|
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
|
response = template.render(**ctx)
|
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
|
|
|
|
|
|
|
|
|
@router.get("/accounts", response_class=HTMLResponse)
|
|
def accounts_page(request: Request):
|
|
"""Account management panel."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
with get_conn() as conn:
|
|
users = conn.execute(
|
|
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
|
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
|
).fetchall()
|
|
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
|
template = env.get_template("accounts.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
|
|
|
|
@router.get("/help", response_class=HTMLResponse)
|
|
def help_page(request: Request):
|
|
"""Comprehensive help & documentation page."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, [])
|
|
# Render via a block-based template so content_html lands in {% block content %}
|
|
block_tpl = env.from_string(
|
|
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
|
)
|
|
return HTMLResponse(block_tpl.render(
|
|
**sidebar,
|
|
request=request,
|
|
page_title="Help",
|
|
title_prefix="Help",
|
|
page_icon="❓",
|
|
content_html="""<style>
|
|
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
|
.help-hero{text-align:center;margin-bottom:48px;}
|
|
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
|
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
|
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
|
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
|
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
|
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
|
.help-card .icon{font-size:20px;}
|
|
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
|
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
|
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
|
.help-card li::before{content:'• ';color:var(--accent);}
|
|
.help-section{margin-bottom:48px;}
|
|
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
|
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
|
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
|
.help-shortcut-row:hover{background:var(--bg-hover);}
|
|
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
|
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
|
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
|
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
|
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
|
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
|
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
|
</style>
|
|
<div class="help-page">
|
|
<div class="help-hero">
|
|
<h1>❓ FlowDeck Help</h1>
|
|
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
|
</div>
|
|
|
|
<div class="help-grid">
|
|
<div class="help-card">
|
|
<h3><span class="icon">🚀</span>Getting Started</h3>
|
|
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
|
<ul>
|
|
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
|
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
|
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
|
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
|
<ul>
|
|
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
|
<li>Drag & drop pages in the sidebar to reorganize</li>
|
|
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
|
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
|
<ul>
|
|
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
|
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
|
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
|
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
|
<ul>
|
|
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
|
<li>Browse repo file trees in the sidebar</li>
|
|
<li>Create & edit files with commit messages</li>
|
|
<li>Sync labels as tags</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
|
<p>Share pages with collaborators or publish them to the web.</p>
|
|
<ul>
|
|
<li>Click <b>Share</b> in the page editor top-right</li>
|
|
<li>Share with specific users or get a public link</li>
|
|
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
|
<p>Find all your content in one place with powerful filtering.</p>
|
|
<ul>
|
|
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
|
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
|
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
|
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
|
<ul>
|
|
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
|
<li>Edits made offline are queued locally and synced automatically</li>
|
|
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>⌨️ Keyboard Shortcuts</h2>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>🔐 Authentication</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
FlowDeck supports three authentication methods:<br>
|
|
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
|
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
|
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
|
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
|
</p>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
|
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
|
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
|
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
|
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
|
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
|
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
|
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
|
</p>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>📶 Offline mode (PWA)</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
|
edits are saved locally, then synchronised when the connection returns.<br><br>
|
|
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
|
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
|
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
|
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
|
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
|
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
|
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
|
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
|
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
|
page. If a page with the same title already exists, the offline copy is renamed
|
|
<i>“Title (copie offline)”</i>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>🔌 API publique v2</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
|
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
|
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
|
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
|
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
|
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
|
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
|
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
|
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
|
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
|
</p>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>💡 Tips</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
|
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
|
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
|
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
|
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
|
</p>
|
|
</div>
|
|
|
|
</div>"""
|
|
))
|
|
|
|
|
|
|
|
|
|
@router.get("/accounts/settings", response_class=HTMLResponse)
|
|
def settings_page(request: Request):
|
|
"""User settings page — profile, forges, tokens."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
# Check forge connections
|
|
gitea_connected = False
|
|
github_connected = False
|
|
if user.get("id"):
|
|
with get_conn() as conn:
|
|
tokens = conn.execute(
|
|
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
|
).fetchall()
|
|
for t in tokens:
|
|
if t["provider"] == "gitea":
|
|
gitea_connected = True
|
|
elif t["provider"] == "github":
|
|
github_connected = True
|
|
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
|
|
template = env.get_template("settings.html")
|
|
response = template.render(**ctx)
|
|
return HTMLResponse(content=response)
|
|
|
|
|
|
# ═══════════ User API endpoints ═══════════
|