v5.2.0: Infrastructure & Polish
- 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:
@@ -6,6 +6,8 @@
|
||||
<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=5.0.0">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
<style>
|
||||
/* ── Mobile responsive (v4.0.2) ── */
|
||||
|
||||
@@ -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 & 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 & 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();
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;}
|
||||
.wizard{width:560px;max-width:94vw;background:var(--bg2);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 12px 40px rgba(0,0,0,.35);}
|
||||
.wizard .brand{font-size:15px;font-weight:700;letter-spacing:.3px;margin-bottom:24px;display:flex;align-items:center;gap:8px;}
|
||||
.wizard .brand svg{width:20px;height:20px;}
|
||||
h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
.sub{color:var(--dim);font-size:14px;margin-bottom:26px;line-height:1.5;}
|
||||
.steps{display:flex;gap:8px;margin-bottom:26px;}
|
||||
.step{flex:1;height:4px;border-radius:2px;background:var(--bg3);transition:background .3s;}
|
||||
.step.active{background:var(--accent);}
|
||||
.step.done{background:var(--success);}
|
||||
.step-label{font-size:12px;color:var(--dim);margin-bottom:8px;}
|
||||
.card{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:14px;cursor:pointer;transition:border-color .15s, transform .1s;}
|
||||
.card:hover{border-color:var(--accent);transform:translateY(-1px);}
|
||||
.card.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
|
||||
.card .card-icon{font-size:22px;margin-bottom:6px;}
|
||||
.card .card-title{font-size:15px;font-weight:600;margin-bottom:2px;}
|
||||
.card .card-desc{font-size:13px;color:var(--dim);}
|
||||
.input{width:100%;padding:11px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;outline:none;margin-bottom:14px;}
|
||||
.input:focus{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-primary:hover{background:var(--accent-h);}
|
||||
.btn-ghost{background:transparent;color:var(--dim);}
|
||||
.btn-ghost:hover{color:var(--text);}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.actions{display:flex;justify-content:space-between;align-items:center;margin-top:8px;}
|
||||
.skip{color:var(--dim);font-size:13px;cursor:pointer;text-decoration:none;}
|
||||
.skip:hover{color:var(--text);}
|
||||
.forge-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
.forge-row:last-child{border-bottom:none;}
|
||||
.forge-logo{width:34px;height:34px;border-radius:9px;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:17px;}
|
||||
.forge-name{font-size:14px;font-weight:600;}
|
||||
.forge-status{font-size:12px;color:var(--dim);}
|
||||
.badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.16);color:#2ea043;}
|
||||
.badge.off{background:var(--bg3);color:var(--dim);}
|
||||
.link{margin-left:auto;font-size:12px;color:var(--accent);text-decoration:none;}
|
||||
.link:hover{text-decoration:underline;}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#0F7B6C;color:#fff;padding:10px 18px;border-radius:10px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:50;display:none;}
|
||||
.toast.error{background:var(--danger);}
|
||||
.avatar{width:40px;height:40px;border-radius:50%;background:var(--accent);display:inline-flex;align-items:center;justify-content:center;font-weight:700;margin-right:10px;}
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="onboarding()" x-init="init()">
|
||||
<div class="toast" id="toast" x-show.transition="toastMsg" x-text="toastMsg" :class="toastError ? 'error':''" x-cloak></div>
|
||||
|
||||
<div class="wizard">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#2383E2" stroke-width="2"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>
|
||||
FlowDeck
|
||||
</div>
|
||||
|
||||
<!-- Stepper -->
|
||||
<div class="steps">
|
||||
<template x-for="(s,i) in steps" :key="i">
|
||||
<div class="step" :class="{ active: step===i, done: step>i }"></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="step-label" x-text="steps[step]"></div>
|
||||
|
||||
<!-- Step 0: welcome -->
|
||||
<section x-show="step===0">
|
||||
<h1>Bienvenue 👋</h1>
|
||||
<p class="sub">Configurons votre espace de travail en 3 étapes. Vous pourrez tout changer plus tard.</p>
|
||||
<button class="btn btn-primary" @click="step=1">Commencer</button>
|
||||
</section>
|
||||
|
||||
<!-- Step 1: workspace -->
|
||||
<section x-show="step===1">
|
||||
<h1>Créez votre espace</h1>
|
||||
<p class="sub">Un workspace regroupe vos pages, bases de données et projets.</p>
|
||||
<input class="input" x-model="wsName" @keydown.enter="createWorkspace()" placeholder="Ex : Équipe Frelon" maxlength="120">
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="createWorkspace()" :disabled="!wsName.trim() || saving">Créer l'espace</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 2: connect forge -->
|
||||
<section x-show="step===2">
|
||||
<h1>Connectez vos dépôts</h1>
|
||||
<p class="sub">Optionnel — syncronisez Gitea ou GitHub. Vous pouvez le faire plus tard dans Préférences → Intégrations.</p>
|
||||
<div class="card" :class="{selected: forgeChoice==='gitea'}" @click="forgeChoice='gitea'">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div class="forge-logo">🔗</div>
|
||||
<div style="flex:1;">
|
||||
<div class="forge-name">Gitea</div>
|
||||
<div class="forge-status">
|
||||
<template x-if="!{{ 'true' if gitea_url_configured else 'false' }}">Non configuré par l'administrateur</template>
|
||||
<template x-if="{{ 'true' if gitea_url_configured else 'false' }}">Disponible — Issues, Kanban, fichiers en sync</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge" :class="giteaLinking ? 'off':''" x-text="giteaLinking ? 'Connexion…' : 'Choisir'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" :class="{selected: forgeChoice==='github'}" @click="forgeChoice='github'">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div class="forge-logo">🐙</div>
|
||||
<div style="flex:1;">
|
||||
<div class="forge-name">GitHub</div>
|
||||
<div class="forge-status">
|
||||
<template x-if="!{{ 'true' if github_url_configured else 'false' }}">Non configuré par l'administrateur</template>
|
||||
<template x-if="{{ 'true' if github_url_configured else 'false' }}">Disponible — dépôts privés & publics</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge" :class="githubLinking ? 'off':''" x-text="githubLinking ? 'Connexion…' : 'Choisir'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="skipForge()">Passer</button>
|
||||
<button class="btn btn-primary" @click="connectForge()" :disabled="!forgeChoice">Connecter</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 3: first project -->
|
||||
<section x-show="step===3">
|
||||
<h1>Créez votre premier projet</h1>
|
||||
<p class="sub">Une page d'accueil avec quelques conseils pour démarrer.</p>
|
||||
<input class="input" x-model="projectTitle" @keydown.enter="createProject()" placeholder="Ex : Welcome to FlowDeck">
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="finish()">Passer</button>
|
||||
<button class="btn btn-primary" @click="createProject()" :disabled="saving">Créer</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
step: 0,
|
||||
wsName: '',
|
||||
forgeChoice: '',
|
||||
projectTitle: 'Welcome to FlowDeck',
|
||||
workspaceId: null,
|
||||
saving: false,
|
||||
giteaLinking: false,
|
||||
githubLinking: false,
|
||||
toastMsg: '',
|
||||
toastError: false,
|
||||
init() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (p.get('linked') === '1') { this.step = 2; this.toastMsg = 'Forge connectée ✅'; setTimeout(()=>this.toastMsg='', 2500); }
|
||||
},
|
||||
toast(msg, err) { this.toastMsg = msg; this.toastError = !!err; setTimeout(()=>this.toastMsg='', 3000); },
|
||||
async createWorkspace() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:this.wsName.trim()})});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
|
||||
this.workspaceId = d.id;
|
||||
this.step = 2;
|
||||
} catch(e) { this.toast('Erreur réseau', true); }
|
||||
finally { this.saving = false; }
|
||||
},
|
||||
connectForge() {
|
||||
if (this.forgeChoice === 'gitea') { this.giteaLinking = true; window.location = '/auth/login?provider=gitea&mode=link&next=/welcome?linked=1'; }
|
||||
else if (this.forgeChoice === 'github') { this.githubLinking = true; window.location = '/auth/login?provider=github&mode=link&next=/welcome?linked=1'; }
|
||||
else this.skipForge();
|
||||
},
|
||||
skipForge() { this.forgeChoice = ''; this.step = 3; },
|
||||
async createProject() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
|
||||
this.toast('Projet créé 🎉');
|
||||
setTimeout(()=> window.location = '/local-workspace?ws=' + (this.workspaceId || ''), 700);
|
||||
} catch(e) { this.toast('Erreur réseau', true); }
|
||||
finally { this.saving = false; }
|
||||
},
|
||||
finish() { window.location = '/workspaces'; }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user