v5.2.0: Infrastructure & Polish
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped

- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
This commit is contained in:
2026-09-10 23:47:35 -04:00
co-authored by Bruno
parent e9b6244ef0
commit 5c350ff8f6
77 changed files with 2447 additions and 293 deletions
+190
View File
@@ -110,6 +110,8 @@
<div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
@@ -123,6 +125,7 @@
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
</div>
</template>
</div>
@@ -274,6 +277,80 @@
</div>
</div>
<!-- API tokens -->
<div x-show="activeSection==='api-tokens'">
<h2>API tokens</h2>
<p class="section-desc">Generate personal access tokens for the public API (<code>/api/v1</code>) — e.g. for scripts and external tools.</p>
<div class="setting-group">
<h3>New token</h3>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Name</div>
<div style="display:flex;gap:8px;">
<input type="text" class="settings-input" x-model="tokenName" placeholder="Ex : CI script, monitoring…" style="flex:1;" @keydown.enter="createApiToken()">
<button class="btn btn-primary" @click="createApiToken()" :disabled="!tokenName.trim()" style="white-space:nowrap;">Generate</button>
</div>
</div>
<div class="setting-row" x-show="newToken" style="flex-direction:column;align-items:stretch;gap:6px;background:rgba(46,160,67,.08);border-radius:8px;">
<div class="setting-label" style="color:var(--toast-success-bg);">Token créé — copiez-le maintenant (affiché une seule fois)</div>
<div style="display:flex;gap:8px;align-items:center;">
<code class="settings-input" x-text="newToken.token" style="flex:1;font-family:var(--font-mono);font-size:12px;user-select:all;"></code>
<button class="btn-sm" @click="copyNewToken()">Copy</button>
</div>
</div>
</div>
<div class="setting-group">
<h3>Tokens existants <span style="text-transform:none;font-size:11px;" x-text="'(' + apiTokens.length + ')'"></span></h3>
<div x-show="!apiTokens.length" class="setting-desc" style="padding:8px 0;">Aucun token pour l'instant.</div>
<template x-for="t in apiTokens" :key="t.id">
<div class="setting-row">
<div style="flex:1;min-width:0;">
<div class="setting-label">
<span x-text="t.name"></span>
<code style="font-size:11px;color:var(--text-dim);margin-left:8px;" x-text="'fd_' + (t.token_prefix||'').slice(0,12)"></code>
</div>
<div class="setting-desc" style="font-size:11px;">
<span x-text="'Créé le ' + (t.created_at||'').slice(0,16)"></span>
<template x-if="t.last_used_at"><span x-text="' · utilisé le ' + t.last_used_at.slice(0,16)"></span></template>
</div>
</div>
<div class="setting-control" style="display:flex;gap:6px;align-items:center;">
<span class="llm-badge" :class="t.revoked ? 'off' : 'ok'" x-text="t.revoked ? 'Révoqué' : 'Actif'"></span>
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeApiToken(t)" x-show="!t.revoked">Révoquer</button>
</div>
</div>
</template>
</div>
</div>
<!-- Sessions -->
<div x-show="activeSection==='sessions'">
<h2>Active sessions</h2>
<p class="section-desc">Appareils actuellement connectés à votre compte. Révoquez une session pour la déconnecter immédiatement.</p>
<div class="setting-group">
<div x-show="!sessions.length" class="setting-desc" style="padding:8px 0;">Aucune session enregistrée.</div>
<template x-for="s in sessions" :key="s.id">
<div class="setting-row">
<div style="flex:1;min-width:0;">
<div class="setting-label">
<span x-text="s.is_current ? 'Cet appareil' : 'Autre appareil'"></span>
<span class="llm-badge" :class="s.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="s.revoked ? 'Révoquée' : (s.is_current ? 'Active' : 'Active')"></span>
</div>
<div class="setting-desc" style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:520px;">
<span x-text="s.user_agent || 'Agent inconnu'"></span>
</div>
<div class="setting-desc" style="font-size:11px;">
<span x-text="'IP: ' + (s.ip_address || '—')"></span>
<span x-text="' · ' + (s.last_seen_at||'').slice(0,16)"></span>
</div>
</div>
<div class="setting-control">
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeSession(s)" x-show="!s.revoked" x-text="s.is_current ? 'Se déconnecter' : 'Révoquer'"></button>
</div>
</div>
</template>
</div>
</div>
<!-- Workspace -->
<div x-show="activeSection==='workspace'">
<h2>Workspace</h2>
@@ -647,6 +724,36 @@
</div>
</div>
<!-- Admin: Backups -->
<div x-show="activeSection==='admin-backups'">
<h2>Backups</h2>
<p class="section-desc">Sauvegardes automatiques de la base (quotidiennes). Cliquez pour en générer une immédiatement.</p>
<div class="setting-group">
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button class="btn btn-primary" @click="runBackup()" :disabled="backupRunning" style="font-size:13px;">
<span x-show="!backupRunning">{{ fd_icon("save",14) }} Lancer une sauvegarde</span>
<span x-show="backupRunning">Sauvegarde…</span>
</button>
<span x-show="backupMsg" x-text="backupMsg" style="font-size:13px;" :style="{color: backupOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Fichier</th><th>Taille</th><th>Modifié</th></tr></thead>
<tbody>
<template x-for="b in backups" :key="b.filename">
<tr>
<td><code style="font-size:12px;" x-text="b.filename"></code></td>
<td x-text="b.size + ' B'" style="text-align:right;"></td>
<td x-text="b.modified_at" style="font-size:12px;color:var(--text-dim);"></td>
</tr>
</template>
<tr x-show="!backups.length"><td colspan="3" style="text-align:center;color:var(--text-dim);padding:20px;" x-text="'Aucune sauvegarde — ou dossier '+ '[' + 'backups' + ']' + ' non accessible.'"></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
@@ -852,6 +959,17 @@ document.addEventListener('alpine:init', function() {
globalCollections: [],
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
// v5.2.0 API tokens & sessions
apiTokens: [],
newToken: null,
tokenName: '',
sessions: [],
// v5.2.0 Backups (admin)
backups: [],
backupRunning: false,
backupMsg: '',
backupOk: false,
async init() {
await this.loadTags();
await this.loadGiteaStatus();
@@ -1544,6 +1662,78 @@ document.addEventListener('alpine:init', function() {
if (r.ok) await this.loadAutomations();
} catch(e) {}
},
// ── v5.2.0 API tokens ──
async loadApiTokens() {
try {
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
var d = await r.json();
this.apiTokens = d.tokens || [];
} catch(e) { this.apiTokens = []; }
},
async createApiToken() {
var name = this.tokenName.trim();
if (!name) return;
try {
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
this.newToken = d;
this.tokenName = '';
await this.loadApiTokens();
} catch(e) { window.showToast && window.showToast('Erreur réseau', 'error'); }
},
async copyNewToken() {
if (!this.newToken) return;
try { await navigator.clipboard.writeText(this.newToken.token); window.showToast && window.showToast('Token copié'); }
catch(e) { window.showToast && window.showToast(this.newToken.token, 'error'); }
},
async revokeApiToken(t) {
if (!confirm('Révoquer le token « ' + t.name + ' » ? Il ne fonctionnera plus immédiatement.')) return;
try {
var r = await fetch('/api/settings/tokens/' + t.id, {method:'DELETE', credentials:'same-origin',
headers: {'X-CSRF-Token': this.getCsrfToken()} });
if (r.ok) await this.loadApiTokens();
} catch(e) {}
},
// ── v5.2.0 Sessions ──
async loadSessions() {
try {
var r = await fetch('/api/settings/sessions', {credentials:'same-origin'});
var d = await r.json();
this.sessions = d.sessions || [];
} catch(e) { this.sessions = []; }
},
async revokeSession(s) {
var msg = s.is_current ? 'Déconnecter cet appareil ?' : 'Révoquer cette session ?';
if (!confirm(msg)) return;
try {
var r = await fetch('/api/settings/sessions/' + s.id + '/revoke', {method:'POST', credentials:'same-origin',
headers: {'X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
if (r.ok) {
if (s.is_current) window.location = '/auth/login?provider=local';
await this.loadSessions();
}
} catch(e) {}
},
// ── v5.2.0 Backups (admin) ──
async loadBackups() {
try {
var r = await this.adminFetch('/api/settings/backups');
var d = await r.json();
this.backups = d.backups || [];
} catch(e) { this.backups = []; }
},
async runBackup() {
this.backupRunning = true; this.backupMsg = ''; this.backupOk = false;
try {
var r = await this.adminFetch('/api/settings/backups/run', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
var d = await r.json();
if (r.ok) { this.backupOk = true; this.backupMsg = '✓ Sauvegarde créée : ' + d.filename; await this.loadBackups(); }
else { this.backupMsg = d.detail || 'Erreur'; }
} catch(e) { this.backupMsg = 'Erreur réseau'; }
finally { this.backupRunning = false; }
},
closeSettings() {
if (window.opener && !window.opener.closed) {
window.opener.location.reload();