feat: éditeur visuel d'automations (pipeline steps) + fix CSP multi-instructions (v7.45.0)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m9s

Added — Settings → Automations, pipeline visuel (API steps v7.0) :
- Cartes ordinees (trigger/condition/delay/action) : resume + edition
  TYPÉE par kind/type (datalist evenements, 7 ops, 8 types d'action avec
  leurs champs reels), ajout/edition/suppression/haut-bas via
  POST/PUT/DELETE /workspace/automations[/steps]/...
- ✨ Convertir le JSON en pipeline (legacy → steps ordonnes) ; textareas
  JSON masques des qu'un step existe.
- Gate E2E « editeur visuel de steps » : creation → edition → ajout →
  carte « Action · webhook » sous CSP reel.

Fixed (trouve par le gate) — 51 expressions Alpine MULTI-INSTRUCTIONS
(`a=1; b()`) = interdites par le parseur CSP (une seule expression par
directive ; ';' = token inattendu) — INVISIBLE pour le scan par tokens :
- Conversion en methodes dans 11 fichiers : nav settings x8 (navTo),
  menu section base x7 (closeAndSetCount/Move/...), parts+editeur x13
  (setSharePerm, more*, markAndSave...), breadcrumb x5 (hover*/goClose),
  library/local x6 (menus popup), board x3 (pickStatus/...), ctx-menu x2
  (addTagAndClear), agent/card/gitea/workspaces x6.
- Scanner dedie scan_semi (inventaire ';' hors chaines) ajoute au lot.

Verifs : 39 templates Jinja parse OK · scan expressions = 0 incompatible
(4 faux positifs en chaines) · **E2E 8/8** · suite **1094/1094** ·
ruff OK · docs a jour
This commit is contained in:
2026-10-02 16:56:29 -04:00
parent b0af1bbfb6
commit 6d7af3fb64
28 changed files with 445 additions and 60 deletions
+4
View File
@@ -21,6 +21,10 @@
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
addTagAndClear(value, color, el){
this.addNewTag(value, color);
if (el) el.value = '';
},
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
+1
View File
@@ -394,6 +394,7 @@
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
},
showHistory(){ this.tab = 'history'; this.loadConversations(); },
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
+3
View File
@@ -70,6 +70,9 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
removeFilterAndRefresh(i) { this.removeFilter(i); this.refreshView(); },
resetFiltersAndRefresh() { this.resetFilters(); this.refreshView(); },
pickStatus(v) { this.toggleStatus(v); this.showStatusMenu = false; this.refreshView(); },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
+5
View File
@@ -368,6 +368,11 @@ document.addEventListener('alpine:init', () => {
},
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
closePrivateEdit() {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
fmtGwDate(pp) {
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
},
+15
View File
@@ -173,6 +173,21 @@ function libraryPage() {
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
// réactivité via Alpine.effect — valable sous les deux builds.
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.sortOpen = false;
this.viewOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.filterOpen = false;
this.viewOpen = false;
},
toggleViewMenu() {
this.viewOpen = !this.viewOpen;
this.filterOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
+14
View File
@@ -2012,6 +2012,20 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
// (réactivité conservée : lecture des données réactives dans l'effect) ──
toggleViewMenu() {
this.viewMenuOpen = !this.viewMenuOpen;
this.sortOpen = false;
this.searchOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.viewMenuOpen = false;
},
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.viewMenuOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
+10
View File
@@ -1125,6 +1125,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
wrap.classList.toggle('full-width',!!this.fullWidth);
wrap.classList.toggle('small-text',!!this.fontSmall);
},
closeInviteSuggest(){ this.inviteSuggestOpen = false; this.inviteUsers = []; },
setSharePerm(a, perm){ this.updateShare(a.id, perm); a.permOpen = false; },
removeShareAndClose(a){ this.removeShare(a.id); a.permOpen = false; },
pickGeneralAccess(v){ this.generalAccess = v; this.accessMenuOpen = false; },
applyIcon(){ this.setIcon(this.iconValue); this.iconOpen = false; },
moreToggleLock(){ this.moreOpen = false; this.toggleLock(); },
moreFullWidth(){ this.moreOpen = false; this.setPageOption('full_width', !this.fullWidth); },
moreFontSmall(){ this.moreOpen = false; this.setPageOption('font_small', !this.fontSmall); },
moreSaveTemplate(){ this.moreOpen = false; this.saveAsTemplate(); },
markAndSave(){ this.dirty = true; this.save(); },
async toggleLock(){
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
const next=!this.isLocked;
+129
View File
@@ -75,6 +75,9 @@ function settingsInit() {
automations: [],
autoRuns: [],
editAutomationId: null,
editSteps: [],
stepEdit: -1,
stepNewKind: 'action',
globalCollections: [],
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
@@ -773,6 +776,128 @@ function settingsInit() {
if (r.ok) { this.githubLinked = false; }
} catch { /* volontaire */ }
},
// ── Éditeur visuel de steps (API v7.0 : /steps CRUD) ──
async loadSteps() {
if (!this.editAutomationId) { this.editSteps = []; return; }
try {
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps',
{credentials:'same-origin'});
var d = await r.json();
this.editSteps = Array.isArray(d) ? d : (d.steps || []);
} catch { this.editSteps = []; }
},
stepSummary(s) {
var c = s.config || {};
if (s.kind === 'trigger') return '\ud83d\udce1 D\u00e9clencheur \u00b7 ' + (c.event || '?');
if (s.kind === 'condition') return '\u2696 Condition \u00b7 ' + (c.property || '?') + ' ' + (c.op || 'eq') + ' ' + (c.value || '');
if (s.kind === 'delay') return '\u23f3 Attente \u00b7 ' + (c.seconds || 0) + ' s';
var extra = c.url || c.message || c.text || c.to || (c.owner ? c.owner + '/' + c.repo : '') || c.title || '';
return '\u26a1 Action \u00b7 ' + (c.type || '?') + (extra ? ' \u2192 ' + String(extra).slice(0, 60) : '');
},
defaultStepConfig(kind) {
if (kind === 'trigger') return { event: 'page.created' };
if (kind === 'condition') return { property: '', op: 'eq', value: '' };
if (kind === 'delay') return { seconds: 60 };
return { type: 'webhook', url: '' };
},
async addStep() {
if (!this.editAutomationId) return;
try {
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps', {
method: 'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({ kind: this.stepNewKind, config: this.defaultStepConfig(this.stepNewKind) })
});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
await this.loadSteps();
this.stepEdit = this.editSteps.length - 1;
} catch { /* volontaire */ }
},
async saveStep(s) {
var cfg = s.config || {};
// forge_issue : labels en cha\u00eene s\u00e9par\u00e9e par virgules → liste
if (cfg.type === 'forge_issue' && typeof cfg.labels === 'string') {
cfg.labels = cfg.labels.split(',').map(function(x){ return x.trim(); }).filter(Boolean);
}
try {
var r = await fetch('/workspace/automations/steps/' + s.id, {
method:'PUT', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({ kind: s.kind, config: cfg })
});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
window.showToast && window.showToast('\u00c9tape enregistr\u00e9e');
await this.loadSteps();
} catch { /* volontaire */ }
},
async delStep(i) {
var s = this.editSteps[i]; if (!s) return;
try {
await fetch('/workspace/automations/steps/' + s.id, {
method:'DELETE', credentials:'same-origin',
headers:{'X-CSRF-Token': this.getCsrfToken()} });
this.stepEdit = -1;
await this.loadSteps();
} catch { /* volontaire */ }
},
async moveStep(i, dir) {
var j = i + dir;
if (j < 0 || j >= this.editSteps.length) return;
var a = this.editSteps[i], b = this.editSteps[j];
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
try {
await fetch('/workspace/automations/steps/' + a.id, {method:'PUT', headers:h,
credentials:'same-origin', body: JSON.stringify({position: b.position})});
await fetch('/workspace/automations/steps/' + b.id, {method:'PUT', headers:h,
credentials:'same-origin', body: JSON.stringify({position: a.position})});
await this.loadSteps();
} catch { /* volontaire */ }
},
async convertToSteps() {
// JSON legacy → pipeline ordonn\u00e9 (trigger, conditions, actions)
if (!this.editAutomationId) return;
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
var base = '/workspace/automations/' + this.editAutomationId + '/steps';
var order = [];
if (this.af.trigger_type !== 'cron') {
var tcfg = {event: this.af.event || 'page.created'};
if (this.af.collection_id) tcfg.collection_id = parseInt(this.af.collection_id, 10);
order.push({kind:'trigger', config: tcfg});
}
try {
JSON.parse(this.af.condition_json || '[]').forEach(function(c){ order.push({kind:'condition', config:c}); });
} catch { /* volontaire */ }
try {
JSON.parse(this.af.actions_json || '[]').forEach(function(a){ order.push({kind:'action', config:a}); });
} catch { /* volontaire */ }
for (var i = 0; i < order.length; i++) {
var r = await fetch(base, {method:'POST', headers:h, credentials:'same-origin',
body: JSON.stringify(order[i])});
if (!r.ok) {
var d = await r.json().catch(function(){ return {}; });
window.showToast && window.showToast(d.detail || 'Conversion \u00e9chou\u00e9e', 'error');
break;
}
}
await this.loadSteps();
},
navTo(sec) {
this.activeSection = sec;
var loaders = {'api-tokens':'loadApiTokens', 'sessions':'loadSessions',
'extensions':'loadClipperDevices', 'automations':'loadAutomations',
'admin-users':'loadAdminUsers', 'admin-audit':'loadAdminAudit',
'admin-backups':'loadBackups', 'admin-sso':'loadSsoConfig'};
if (loaders[sec] && this[loaders[sec]]) this[loaders[sec]]();
},
setAuditSource(s) { this.auditSource = s; this.loadAuditLogs(); },
auditNext() { this.auditOffset += this.auditPageSize; this.loadAuditLogs(false); },
editUserRow(u) {
this.editingUser = u;
this.editUserForm = { login: u.login, name: u.full_name, email: u.email,
is_admin: u.is_admin, is_active: u.is_active };
},
async loadAutomations() {
try { await this.loadCollectionsForForm(); } catch { /* volontaire */ }
try {
@@ -826,9 +951,13 @@ function settingsInit() {
condition_json: a.condition_json || '[]',
actions_json: a.actions_json || '[]'
};
this.stepEdit = -1;
this.loadSteps();
},
cancelEditAutomation() {
this.editAutomationId = null;
this.editSteps = [];
this.stepEdit = -1;
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
},
async toggleAutomation(id, currentlyEnabled) {
+1
View File
@@ -11,6 +11,7 @@ function workspacesPage() {
activeId: null,
showCreate: false,
showRename: false,
closeCreateRename() { this.showCreate = false; this.showRename = false; },
wsName: '',
renameTarget: null,