- 46 appels non-GET équipés de `X-CSRF-Token` (expression cookie en ligne, portée indifférente) : agent_panel (9), settings (12), local_workspace (15), gitea_workspace (4), workspace (2), workspaces (5), library (2), welcome (2 en v7.3.5) - 5 derniers préfixes sortis d'`EXCLUDED_PATHS` : /api/workspace (+/api/workspaces), /api/local-workspace, /api/settings, /api/gitea, /api/agent - il ne reste exempté que : Bearer (/api/webhook, /api/v1, /api/v2, /scim/v2), callbacks /auth/*, pages publiques (/s/, /f/), /api/csrf-token et /api/frontend-error - vérif : `node --check` des <script> des 39 templates → 0 échec (avant et après) - tests : `anon_csrf()` sur les 5 tests d'anonymat devenus CSRF-first - suite **1026/1026** · `ruff check app tests` OK
330 lines
14 KiB
HTML
330 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_title %}Workspaces{% endblock %}
|
|
{% block title_prefix %}Workspaces{% endblock %}
|
|
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
|
|
|
{% block topbar %}
|
|
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
|
{% set page_icon = "home" %}
|
|
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
|
{% include '_header.html' %}
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.ws-page{max-width:1000px;margin:0 auto;padding:40px 24px;}
|
|
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
|
|
.ws-header h1{font-size:24px;}
|
|
.ws-section{margin-bottom:40px;}
|
|
.ws-section-title{font-size:14px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
|
.ws-section-title .badge{font-size:11px;background:var(--bg-tertiary);color:var(--text-dim);padding:1px 8px;border-radius:10px;}
|
|
|
|
.ws-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;}
|
|
.ws-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:background 150ms, border-color 150ms;}
|
|
.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-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;}
|
|
|
|
/* Gitea org grouping */
|
|
.gitea-org{margin-bottom:16px;}
|
|
.gitea-org-header{font-size:14px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px;margin-bottom:8px;}
|
|
.gitea-org-header img{border-radius:4px;}
|
|
.gitea-org-avatar{width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:14px;}
|
|
.gitea-empty{padding:24px;text-align:center;color:var(--text-dim);font-size:14px;border:1px dashed var(--border);border-radius:var(--radius);}
|
|
.gitea-connect{display:inline-block;margin-top:8px;color:var(--accent);cursor:pointer;font-size:13px;}
|
|
.gitea-connect:hover{text-decoration:underline;}
|
|
.gitea-loading{color:var(--text-dim);font-size:13px;padding:12px 0;}
|
|
|
|
/* 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);}
|
|
[data-theme="light"] .org-tab-badge{background:rgba(0,0,0,.08);}
|
|
.search-input:focus{border-color:var(--accent);}
|
|
</style>
|
|
|
|
<div class="ws-page" x-data="workspacesPage()">
|
|
|
|
<div class="ws-header">
|
|
<h1>{{ fd_icon("home",20) }} Workspaces</h1>
|
|
</div>
|
|
|
|
<!-- ── Local Workspaces ── -->
|
|
<div class="ws-section">
|
|
<div class="ws-section-title">{{ fd_icon("folder",16) }} My Workspaces <span class="badge" x-text="workspaces.length"></span></div>
|
|
<div class="ws-grid">
|
|
<template x-for="ws in workspaces" :key="'local-'+ws.id">
|
|
<div class="ws-card" :class="{active:ws.id===activeId}" @click="selectLocal(ws)">
|
|
<div class="ws-card-name" x-text="ws.name"></div>
|
|
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
|
|
<div class="ws-card-actions" @click.stop>
|
|
<button class="btn-sm" @click="renameWs(ws)">{{ fd_icon("edit",14) }}</button>
|
|
<button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(ws)">{{ fd_icon("trash",14) }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="ws-card btn-new" @click="showCreate=true" style="display:flex;align-items:center;justify-content:center;height:auto;min-height:140px;">
|
|
<span style="font-size:48px;line-height:1;">+</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Gitea Projects ── -->
|
|
<div class="ws-section">
|
|
<div class="ws-section-title">{{ fd_icon("link",16) }} Gitea Projects <span class="badge" x-text="giteaTotal"></span></div>
|
|
|
|
<!-- Not linked -->
|
|
<div class="gitea-empty" x-show="giteaStatus==='not_linked'">
|
|
<div>Connect your Gitea account to see your projects.</div>
|
|
<a class="gitea-connect" href="/auth/login?provider=gitea&mode=link">{{ fd_icon("link",14) }} Connect Gitea</a>
|
|
<span style="margin:0 8px;color:var(--text-dim);">or</span>
|
|
<a class="gitea-connect" href="/auth/login?provider=gitea">{{ fd_icon("edit",14) }} Register with Gitea</a>
|
|
</div>
|
|
|
|
<!-- Loading skeletons -->
|
|
<div x-show="giteaStatus==='loading'" class="ws-grid">
|
|
<template x-for="i in 6" :key="i">
|
|
<div class="ws-card" style="pointer-events:none;">
|
|
<div class="skeleton" style="height:16px;width:65%;margin-bottom:8px;"></div>
|
|
<div class="skeleton" style="height:12px;width:45%;margin-bottom:6px;"></div>
|
|
<div class="skeleton" style="height:12px;width:80%;"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div class="gitea-empty" x-show="giteaStatus==='error'">
|
|
<div>{{ fd_icon("alert-triangle",14) }} Could not load Gitea projects.</div>
|
|
<button class="gitea-connect" @click="loadGitea()">Retry</button>
|
|
</div>
|
|
|
|
<!-- Projects (tabs by org + search) -->
|
|
<div x-show="giteaStatus==='ok' && giteaGroups.length > 0">
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
|
<!-- Org tabs -->
|
|
<div style="display:flex;gap:4px;overflow-x:auto;flex:1;min-width:0;">
|
|
<button class="org-tab" :class="{active:activeOrg==='all'}" @click="activeOrg='all'">
|
|
All <span class="org-tab-badge" x-text="giteaTotal"></span>
|
|
</button>
|
|
<template x-for="g in giteaGroups" :key="g.org">
|
|
<button class="org-tab" :class="{active:activeOrg===g.org}" @click="activeOrg=g.org">
|
|
<span x-text="g.org"></span>
|
|
<span class="org-tab-badge" x-text="g.projects.length"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
<!-- Search -->
|
|
<input type="text" class="search-input" x-model="giteaSearch" placeholder="Filter projects…" style="width:200px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;">
|
|
</div>
|
|
|
|
<!-- Project cards (filtered) -->
|
|
<template x-for="group in filteredGroups" :key="group.org">
|
|
<div class="gitea-org">
|
|
<div class="gitea-org-header">
|
|
<span class="gitea-org-avatar" x-text="group.org.charAt(0).toUpperCase()"></span>
|
|
<span x-text="group.org"></span>
|
|
</div>
|
|
<div class="ws-grid">
|
|
<template x-for="proj in group.projects" :key="proj.id">
|
|
<div class="ws-card" @click="openGitea(proj)">
|
|
<div class="ws-card-name" x-text="proj.name"></div>
|
|
<div class="ws-card-sub">
|
|
<span x-text="group.org"></span>
|
|
<span style="margin:0 4px">/</span>
|
|
<span x-text="proj.name"></span>
|
|
</div>
|
|
<div class="ws-card-count">
|
|
<span x-show="proj.private" style="margin-right:6px">{{ fd_icon("lock",14) }}</span>
|
|
<span x-text="proj.description || proj.language || ''" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px;"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="gitea-empty" x-show="filteredGroups.length===0" style="margin-top:12px;">
|
|
No projects match your search.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No projects at all -->
|
|
<div class="gitea-empty" x-show="giteaStatus==='ok' && giteaTotal==0 && giteaGroups.length===0">
|
|
No Gitea projects found.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create/Rename dialog -->
|
|
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
|
|
<div class="dialog-box">
|
|
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
|
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
|
<div class="dialog-actions">
|
|
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
|
|
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script data-cfasync="false">
|
|
function workspacesPage() {
|
|
return {
|
|
workspaces: [],
|
|
activeId: null,
|
|
showCreate: false,
|
|
showRename: false,
|
|
wsName: '',
|
|
renameTarget: null,
|
|
|
|
// Gitea
|
|
giteaStatus: 'loading', // loading|ok|not_linked|error
|
|
giteaGroups: [],
|
|
giteaTotal: 0,
|
|
activeOrg: 'all',
|
|
giteaSearch: '',
|
|
|
|
get filteredGroups() {
|
|
var self = this;
|
|
var q = (this.giteaSearch || '').toLowerCase();
|
|
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
|
|
if (!q) return groups;
|
|
return groups.map(function(g) {
|
|
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
|
|
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
|
|
})};
|
|
}).filter(function(g) { return g.projects.length > 0; });
|
|
},
|
|
|
|
async init() {
|
|
await this.load();
|
|
await this.loadGitea();
|
|
},
|
|
|
|
async load() {
|
|
const r = await fetch('/api/workspaces');
|
|
const d = await r.json();
|
|
this.workspaces = d.workspaces || [];
|
|
this.activeId = d.active_id;
|
|
},
|
|
|
|
async selectLocal(ws) {
|
|
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
|
|
window.location = '/local-workspace';
|
|
},
|
|
|
|
async doCreate() {
|
|
if (!this.wsName.trim()) return;
|
|
await fetch('/api/workspaces', {
|
|
method:'POST',
|
|
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
|
body:JSON.stringify({name:this.wsName.trim()})
|
|
});
|
|
this.wsName = '';
|
|
this.showCreate = false;
|
|
await this.load();
|
|
},
|
|
|
|
renameWs(ws) {
|
|
this.renameTarget = ws;
|
|
this.wsName = ws.name;
|
|
this.showRename = true;
|
|
},
|
|
|
|
async doRename() {
|
|
if (!this.wsName.trim()||!this.renameTarget) return;
|
|
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
|
method:'PUT',
|
|
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
|
body:JSON.stringify({name:this.wsName.trim()})
|
|
});
|
|
this.wsName = '';
|
|
this.showRename = false;
|
|
this.renameTarget = null;
|
|
await this.load();
|
|
},
|
|
|
|
async deleteWs(ws) {
|
|
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
|
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
|
|
await this.load();
|
|
},
|
|
|
|
// ── Gitea ──
|
|
|
|
async loadGitea() {
|
|
this.giteaStatus = 'loading';
|
|
try {
|
|
// Get orgs
|
|
const orgsRes = await fetch('/api/gitea/orgs');
|
|
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
|
|
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
|
|
const orgsData = await orgsRes.json();
|
|
const orgs = orgsData.orgs || [];
|
|
|
|
// Fetch repos: user repos + org repos
|
|
const groups = [];
|
|
|
|
// Get username for the "personal" tab
|
|
let myLogin = 'Me';
|
|
try {
|
|
const meRes = await fetch('/auth/user');
|
|
if (meRes.ok) {
|
|
const meData = await meRes.json();
|
|
myLogin = meData.user?.login || 'Me';
|
|
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
|
|
}
|
|
} catch(e) {}
|
|
|
|
// User repos (no org filter)
|
|
try {
|
|
const userRes = await fetch('/api/gitea/projects');
|
|
if (userRes.ok) {
|
|
const d = await userRes.json();
|
|
const repos = d.projects || [];
|
|
if (repos.length) {
|
|
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
|
|
// Org repos
|
|
for (const org of orgs) {
|
|
try {
|
|
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
|
|
if (res.ok) {
|
|
const d = await res.json();
|
|
if (d.projects && d.projects.length) {
|
|
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
this.giteaGroups = groups;
|
|
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
|
this.giteaStatus = 'ok';
|
|
} catch(e) {
|
|
this.giteaStatus = 'error';
|
|
}
|
|
},
|
|
|
|
openGitea(proj) {
|
|
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
|
|
const repo = proj.name;
|
|
if (owner && repo) {
|
|
// Set workspace cookie so sidebar shows tree
|
|
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
|
|
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|