feat: fallback token admin GET + Private Pages persistantes
FlowDeck CI / test (push) Failing after 17s
FlowDeck CI / docker (push) Has been skipped

1. _require_gitea: fallback token admin pour les GET (orgs, projects, tree, file)
   _require_user_gitea: token OAuth requis pour les write (save, delete)

2. Private Pages: table gitea_private_pages (user_id, owner, repo, title, content)
   API CRUD: GET/POST/PUT/DELETE /api/gitea/projects/{o}/{r}/private-pages
   UI: liste + éditeur dans le workspace Gitea, lien 🔒 Private Pages dans sidebar
   Lié logiquement au projet (owner+repo), conservé entre sessions
This commit is contained in:
2026-07-13 17:01:19 -04:00
parent 6b000d892b
commit caa00c54bc
3 changed files with 257 additions and 6 deletions
+14
View File
@@ -395,6 +395,20 @@ def init_db():
except sqlite3.OperationalError:
pass
conn.commit()
# v2.7: Private pages for Gitea workspaces
conn.execute("""
CREATE TABLE IF NOT EXISTS gitea_private_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
gitea_owner TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'Untitled',
content TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
@contextmanager
+117 -3
View File
@@ -6,7 +6,16 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401."""
"""Return a per-user GiteaClient, falling back to admin token for read-only ops."""
from app.services.gitea_client import get_user_gitea_client, GiteaClient
client = get_user_gitea_client(request)
if not client:
client = GiteaClient() # fallback to server admin token
return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
@@ -79,7 +88,7 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
async def save_file(request: Request, owner: str, repo: str):
"""Create or update a file in the repo."""
import json
gitea = _require_gitea(request)
gitea = _require_user_gitea(request)
try:
body = await request.json()
except Exception:
@@ -102,7 +111,7 @@ async def save_file(request: Request, owner: str, repo: str):
async def delete_file(request: Request, owner: str, repo: str):
"""Delete a file from the repo."""
import json
gitea = _require_gitea(request)
gitea = _require_user_gitea(request)
path = request.query_params.get("path", "")
sha = request.query_params.get("sha", "")
message = request.query_params.get("message", "Delete via FlowDeck")
@@ -152,3 +161,108 @@ async def disconnect_gitea(request: Request):
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
conn.commit()
return {"status": "ok"}
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
@router.get("/projects/{owner}/{repo}/private-pages")
async def list_private_pages(owner: str, repo: str, request: Request):
"""List private pages for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"pages": []})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, updated_at FROM gitea_private_pages WHERE user_id=? AND gitea_owner=? AND gitea_repo=? ORDER BY updated_at DESC",
(user["id"], owner, repo),
).fetchall()
return {"pages": [{"id": r["id"], "title": r["title"], "updated_at": r["updated_at"]} for r in rows]}
@router.post("/projects/{owner}/{repo}/private-pages")
async def create_private_page(owner: str, repo: str, request: Request):
"""Create a new private page for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "Untitled").strip() or "Untitled"
with get_conn() as conn:
cursor = conn.execute(
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?,?,?,?)",
(user["id"], owner, repo, title),
)
conn.commit()
return {"status": "ok", "page": {"id": cursor.lastrowid, "title": title}}
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Get a single private page."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(page_id, user["id"], owner, repo),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
return {"page": {"id": row["id"], "title": row["title"], "content": row["content"], "updated_at": row["updated_at"]}}
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Update a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "").strip()
content = body.get("content", "")
with get_conn() as conn:
if title:
conn.execute(
"UPDATE gitea_private_pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(title, page_id, user["id"], owner, repo),
)
conn.execute(
"UPDATE gitea_private_pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(content, page_id, user["id"], owner, repo),
)
conn.commit()
return {"status": "ok"}
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Delete a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
conn.execute(
"DELETE FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(page_id, user["id"], owner, repo),
)
conn.commit()
return {"status": "ok"}
+126 -3
View File
@@ -82,11 +82,52 @@
</template>
<!-- Empty state -->
<template x-if="!showFile && !showEditor">
<template x-if="!showFile && !showEditor && !showPrivate">
<div class="gw-content">
<div class="empty-state">
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
<p>Select a file from the sidebar to view it.</p>
<p>Select a file from the sidebar to view it, or browse private pages.</p>
</div>
</div>
</template>
<!-- Private pages view -->
<template x-if="showPrivate && !editingPrivate">
<div class="gw-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3>🔒 Private Pages</h3>
<button class="btn btn-primary" @click="newPrivate()">+ New Page</button>
</div>
<template x-if="privatePages.length === 0">
<div class="empty-state" style="padding:40px 20px;">
<p>No private pages yet. Create one to store notes linked to this project.</p>
</div>
</template>
<template x-for="pp in privatePages" :key="pp.id">
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button>
</div>
</template>
</div>
</template>
<!-- Private page editor -->
<template x-if="editingPrivate">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">💾 Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
</div>
</div>
</template>
@@ -102,6 +143,11 @@ function giteaWorkspace() {
owner, repo,
showFile: false,
showEditor: false,
showPrivate: false,
privatePages: [],
editingPrivate: null,
editingPrivateTitle: '',
editingPrivateContent: '',
filePath: '',
fileContent: '',
fileSize: 0,
@@ -137,9 +183,24 @@ function giteaWorkspace() {
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file');
li.setAttribute('data-gitea-sha', item.sha || '');
li.style.cssText = 'padding-left:12px;cursor:pointer;';
li.innerHTML = '<span class=\"page-icon\">' + icon + '</span><span class=\"page-name\">' + item.name + '</span>';
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
container.appendChild(li);
});
// Add Private Pages link
var sep = document.createElement('li');
sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0;';
container.appendChild(sep);
var pli = document.createElement('li');
pli.className = 'sidebar-item';
pli.style.cssText = 'padding-left:12px;cursor:pointer;';
pli.innerHTML = '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span>';
pli.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var gw = document.querySelector('[x-data]').__x.$data;
gw.showPrivate = true; gw.showFile = false; gw.showEditor = false;
gw.loadPrivatePages();
});
container.appendChild(pli);
} catch(e) { console.error('Sidebar tree load failed:', e); }
},
@@ -266,6 +327,68 @@ function giteaWorkspace() {
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
},
// ── Private Pages ──
async loadPrivatePages() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
} catch(e) {}
},
newPrivate() {
this.editingPrivate = 'new';
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
async openPrivate(id) {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
if (r.ok) {
var d = await r.json();
this.editingPrivate = id;
this.editingPrivateTitle = d.page.title;
this.editingPrivateContent = d.page.content;
}
} catch(e) {}
},
async savePrivate() {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
var method = 'POST';
if (this.editingPrivate !== 'new') {
url += '/' + this.editingPrivate;
method = 'PUT';
}
try {
var r = await fetch(url, {
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
});
if (r.ok) {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
await this.loadPrivatePages();
}
} catch(e) {}
},
async deletePrivate(id) {
if (!confirm('Delete this private page?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
});
if (r.ok) await this.loadPrivatePages();
} catch(e) {}
},
getCsrfToken() {
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
},
};
}
</script>