Le bouton close de Settings devait être cliqué deux fois. Symptomatique depuis
le menu utilisateur ; depuis /workspaces ça marchait.
Cause : closeSettings() faisait history.back() PUIS window.location.reload()
après un délai FIXE de 100 ms. Le retour est une navigation asynchrone : quand
elle n'est pas terminée en 100 ms, le reload() retombe sur /settings et le
panneau « se ferme puis revient ». La profondeur d'historique diffère selon le
parcours — d'où le caractère intermittent et le fait que /workspaces
échappait au symptôme.
Correctif : location.replace() vers la page d'origine. Une seule navigation
déterministe — ni history.back(), ni popstate, ni rechargement différé. Aucun
chemin ne peut plus se terminer sans navigation. Effet secondaire assumé et
souhaitable : « Retour » ne rouvre plus Settings.
La branche window.opener ne fait plus de return inconditionnel : window.close()
échoue en silence quand l'onglet n'a pas été ouvert par du script, et l'ancien
code abandonnait alors sans navigation de repli — le bouton semblait mort.
CONTRÔLE NÉGATIF FAIT — le même spec a été exécuté contre deux builds :
ancien closeSettings() -> le parcours menu-utilisateur ÉCHOUE
(Received: "…/settings"), /workspaces passe ;
correctif -> les deux passent.
Le test verrouille aussi que le panneau position:fixed n'est plus peint
(.settings-overlay absent), qui est le symptôme exact rapporté.
Correction de diagnostic : le diagnostic posé en v7.69.3 (« Alpine ne lie pas
le composant x-data="settingsInit()" ») était FAUX — un artefact de mon banc
d'essai target="_blank", pas le parcours réel. La page étant réactive après le
premier clic, le handler s'exécutait bien. ROADMAP.md et CHANGELOG.md § v7.69.3
corrigés pour ne pas laisser une piste fausse en héritage.
pytest 1421 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.4
e2e 18/18 verts sur l'instance déployée
1243 lines
56 KiB
JavaScript
1243 lines
56 KiB
JavaScript
if (!window.__fdSettingsScriptsLoaded) {
|
|
window.__fdSettingsScriptsLoaded = true;
|
|
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
|
|
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
|
|
|
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
|
|
|
// globales window — probe « Undefined variable: settingsInit »).
|
|
|
|
if (window.Alpine) { Alpine.data('settingsInit', settingsInit); fdRefreshXData('settingsInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
|
function settingsInit() {
|
|
return {
|
|
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
|
historyBack() { window.history.back(); },
|
|
fmtLastLogin(u) {
|
|
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
|
|
},
|
|
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
|
|
|
activeSection: 'account',
|
|
// Mobile side-navigation drawer for the settings panel (hamburger in topbar)
|
|
navOpen: false,
|
|
closeNavOnMobile() { this.navOpen = false; },
|
|
allTags: [],
|
|
newTagName: '',
|
|
newTagColor: '#787774',
|
|
deletingTag: null,
|
|
renamingTag: null,
|
|
renameValue: '',
|
|
presetColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
|
avatarUrl: ST.avatar_url,
|
|
avatarColor: ST.avatar_color,
|
|
avatarColors: ['#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#448361','#337EA9','#9065B0','#C94D8B','#787774','#3A3A3A'],
|
|
defaultView: localStorage.getItem('fd_default_view') || 'tree',
|
|
userInfo: {full_name: ST.full_name, login: ST.login, email: ST.email},
|
|
userIsAdmin: ST.is_admin,
|
|
notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
|
|
userTimezone: '', userZones: [],
|
|
// Auth & Integrations
|
|
authMethod: ST.auth_method,
|
|
giteaLinked: false,
|
|
githubLinked: false,
|
|
// Theme
|
|
theme: localStorage.getItem('fd_theme') || 'light',
|
|
// Account form
|
|
accountForm: {login:ST.login, full_name:ST.full_name, email:ST.email, password:''},
|
|
accountSaving: false,
|
|
accountMsg: '',
|
|
pwVisible: false,
|
|
// Admin data
|
|
adminUsers: [],
|
|
adminStats: {},
|
|
auditEntries: [],
|
|
auditLogs: [],
|
|
showCreateUser: false,
|
|
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
|
|
editingUser: null,
|
|
editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true},
|
|
// LLM / Agent config
|
|
llmProviders: [],
|
|
llmProviderModels: [],
|
|
llmForm: {provider:'offline', model:'', api_key:'', api_base:''},
|
|
llmHasKey: false,
|
|
llmSaving: false,
|
|
llmTesting: false,
|
|
llmMsg: '', llmMsgOk: false,
|
|
llmTestMsg: '', llmTestOk: false,
|
|
llmKeyVisible: false,
|
|
llmDefaultProvider: 'offline',
|
|
llmActiveCount: 0,
|
|
llmDefaultFunctional: false,
|
|
llmSummaryTitle: '',
|
|
llmSummaryDesc: '',
|
|
// Per-user provider keys (multi-providers)
|
|
keyForms: {},
|
|
llmSearch: '',
|
|
selectedProviderId: null,
|
|
|
|
// Automations (v5.1.0)
|
|
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:'[]' },
|
|
|
|
// v5.2.0 API tokens & sessions
|
|
apiTokens: [],
|
|
newToken: null,
|
|
tokenName: '',
|
|
sessions: [],
|
|
// v6.2.0 Web Clipper
|
|
clipperDevices: [],
|
|
clipTestUrl: 'https://example.com',
|
|
clipTesting: false,
|
|
clipTestMsg: '',
|
|
clipTestOk: false,
|
|
// v5.2.0 Backups (admin)
|
|
backups: [],
|
|
backupRunning: false,
|
|
backupMsg: '',
|
|
backupOk: false,
|
|
|
|
// v6.7.0 SSO / Enterprise (admin)
|
|
ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''},
|
|
ssoAttrJson: '{}',
|
|
ssoGroups: [],
|
|
ssoWorkspaces: [],
|
|
ssoSource: 'db',
|
|
ssoProvisioned: 0,
|
|
ssoSaving: false,
|
|
ssoMsg: '',
|
|
ssoOk: false,
|
|
|
|
async init() {
|
|
await this.loadTags();
|
|
await this.loadGiteaStatus();
|
|
await this.loadGithubStatus();
|
|
await this.loadNotifPrefs();
|
|
await this.loadUserLlm();
|
|
if (this.userIsAdmin) await this.loadLlmConfig();
|
|
},
|
|
|
|
async loadNotifPrefs() {
|
|
try {
|
|
var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
|
|
var d = await r.json();
|
|
this.notifPrefs = Object.assign({comments:true, mentions:true, reminders:true, assignments:true}, d.prefs || {});
|
|
} catch { /* volontaire */ }
|
|
try {
|
|
var tz = await fetch('/api/notifications/timezone', {credentials:'same-origin'});
|
|
var td = await tz.json();
|
|
this.userTimezone = td.timezone || '';
|
|
this.userZones = td.zones || [];
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async saveNotifPrefs() {
|
|
try {
|
|
await fetch('/api/notifications/prefs', {
|
|
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify(this.notifPrefs)
|
|
});
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async saveTimezone() {
|
|
try {
|
|
var r = await fetch('/api/notifications/timezone', {
|
|
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify({timezone: this.userTimezone})
|
|
});
|
|
if (r.ok && typeof window.showToast === 'function') window.showToast('Timezone saved');
|
|
} catch { /* volontaire */ }
|
|
},
|
|
|
|
async loadTags() {
|
|
try {
|
|
var r = await fetch('/api/settings/tags/all');
|
|
var d = await r.json();
|
|
this.allTags = d.tags || [];
|
|
} catch { this.allTags = []; }
|
|
},
|
|
|
|
async createTag() {
|
|
var n = this.newTagName.trim();
|
|
if (!n) return;
|
|
var r = await fetch('/api/settings/tags', {
|
|
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
|
body: JSON.stringify({name: n, color: this.newTagColor})
|
|
});
|
|
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
|
},
|
|
|
|
async updateTagColor(id, color) {
|
|
await fetch('/api/settings/tags/' + id, {
|
|
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
|
body: JSON.stringify({color: color})
|
|
});
|
|
await this.loadTags();
|
|
},
|
|
|
|
async deleteTag(id) {
|
|
if (!confirm('Delete this tag?')) return;
|
|
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
|
|
await this.loadTags();
|
|
},
|
|
|
|
async renameTag(tag) {
|
|
this.renamingTag = tag;
|
|
this.renameValue = tag.name;
|
|
// Focus the input after Alpine renders the modal
|
|
this.$nextTick(function() {
|
|
var input = document.querySelector('.modal-box input[type=text]');
|
|
if (input) { input.focus(); input.select(); }
|
|
});
|
|
},
|
|
|
|
async confirmRenameTag() {
|
|
var tag = this.renamingTag;
|
|
var newName = this.renameValue.trim();
|
|
if (!tag || !newName || newName.toLowerCase() === tag.name.toLowerCase()) {
|
|
this.renamingTag = null; return;
|
|
}
|
|
await fetch('/api/settings/tags/' + tag.id, {
|
|
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
|
|
body: JSON.stringify({name: newName})
|
|
});
|
|
this.renamingTag = null;
|
|
await this.loadTags();
|
|
},
|
|
|
|
async confirmDeleteTag() {
|
|
var tag = this.deletingTag;
|
|
if (!tag) return;
|
|
await fetch('/api/settings/tags/' + tag.id, { method: 'DELETE' });
|
|
this.deletingTag = null;
|
|
await this.loadTags();
|
|
},
|
|
|
|
// ── Admin ──
|
|
getCsrfToken() {
|
|
return document.cookie.split('; ').find(r => r.startsWith('csrf_token='))?.split('=')[1] || '';
|
|
},
|
|
async adminFetch(url, opts) {
|
|
opts = opts || {};
|
|
opts.headers = opts.headers || {};
|
|
opts.headers['X-CSRF-Token'] = this.getCsrfToken();
|
|
return await fetch(url, opts);
|
|
},
|
|
async loadAdminUsers() {
|
|
try {
|
|
var r = await this.adminFetch('/api/admin/users');
|
|
var d = await r.json();
|
|
this.adminUsers = d.users || [];
|
|
} catch { this.adminUsers = []; }
|
|
await this.loadAdminStats();
|
|
},
|
|
|
|
async loadAdminStats() {
|
|
try {
|
|
var r = await this.adminFetch('/api/admin/stats');
|
|
this.adminStats = await r.json();
|
|
} catch { this.adminStats = {}; }
|
|
},
|
|
|
|
async loadAdminAudit() {
|
|
await this.loadAuditLogs();
|
|
},
|
|
|
|
// ── Unified Audit API (v7.3.0) ──
|
|
auditSource: 'all',
|
|
auditActor: '',
|
|
auditAction: '',
|
|
auditOffset: 0,
|
|
auditPageSize: 50,
|
|
auditLoading: false,
|
|
auditHasMore: false,
|
|
async loadAuditLogs(reset) {
|
|
if (reset !== false) { this.auditOffset = 0; }
|
|
this.auditLoading = true;
|
|
try {
|
|
const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
|
|
if (this.auditActor) params.set('actor', this.auditActor);
|
|
if (this.auditAction) params.set('action', this.auditAction);
|
|
const r = await fetch('/api/v2/audit/logs?' + params.toString());
|
|
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
|
const d = await r.json();
|
|
const logs = d.logs || [];
|
|
this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
|
|
this.auditHasMore = logs.length >= this.auditPageSize;
|
|
} catch {
|
|
if (reset !== false) this.auditLogs = [];
|
|
this.auditHasMore = false;
|
|
}
|
|
this.auditLoading = false;
|
|
},
|
|
auditCsvUrl() {
|
|
const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
|
|
if (this.auditActor) params.set('actor', this.auditActor);
|
|
if (this.auditAction) params.set('action', this.auditAction);
|
|
return '/api/v2/audit/logs?' + params.toString();
|
|
},
|
|
|
|
async adminCreateUser() {
|
|
var u = this.newUser;
|
|
if (!u.login || !u.password) return;
|
|
try {
|
|
var r = await this.adminFetch('/api/admin/users', {
|
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify({login:u.login, name:u.name, email:u.email, password:u.password, is_admin:u.is_admin?1:0})
|
|
});
|
|
if (r.ok) {
|
|
this.newUser = {login:'',name:'',email:'',password:'',is_admin:false};
|
|
this.showCreateUser = false;
|
|
await this.loadAdminUsers();
|
|
} else {
|
|
var d = await r.json();
|
|
window.showToast(d.error || 'Failed to create user', 'error');
|
|
}
|
|
} catch { window.showToast('Network error', 'error'); }
|
|
},
|
|
|
|
async adminUpdateUser() {
|
|
var f = this.editUserForm;
|
|
if (!f.login) return;
|
|
var body = {name: f.name, email: f.email, is_admin: f.is_admin?1:0, is_active: f.is_active?1:0};
|
|
if (f.password) body.password = f.password;
|
|
try {
|
|
var r = await this.adminFetch('/api/admin/users/' + this.editingUser.id, {
|
|
method: 'PUT', headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (r.ok) {
|
|
this.editingUser = null;
|
|
await this.loadAdminUsers();
|
|
}
|
|
} catch { /* volontaire */ }
|
|
},
|
|
|
|
async adminDeleteUser(id) {
|
|
if (!confirm('Permanently delete this user and all their data?')) return;
|
|
try {
|
|
await this.adminFetch('/api/admin/users/' + id, { method: 'DELETE' });
|
|
await this.loadAdminUsers();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
|
|
// ── LLM / Agent ──
|
|
keyForm(id) {
|
|
if (!this.keyForms[id]) {
|
|
this.keyForms[id] = {api_key:'', api_base:'', model:'', models:[], has_key:false,
|
|
keyVisible:false, modelLoading:false, saving:false, testing:false,
|
|
deleting:false, last_error:'', msg:'', ok:false};
|
|
}
|
|
return this.keyForms[id];
|
|
},
|
|
keyModels(id) {
|
|
var p = this.providerById(id);
|
|
var base = ((p && p.models) || []).slice();
|
|
var f = this.keyForms[id];
|
|
if (f && f.models) f.models.forEach(m => { if (base.indexOf(m) === -1) base.push(m); });
|
|
return base;
|
|
},
|
|
providerById(id) {
|
|
return (this.llmProviders || []).find(x => x.id === id) || null;
|
|
},
|
|
userProviders() {
|
|
return (this.llmProviders || []).filter(x => x.id !== 'offline');
|
|
},
|
|
selectedProvider() {
|
|
return this.providerById(this.selectedProviderId);
|
|
},
|
|
filteredProviders() {
|
|
var q = (this.llmSearch || '').trim().toLowerCase();
|
|
var list = this.userProviders();
|
|
if (!q) return list;
|
|
return list.filter(function(p){
|
|
return (p.name || '').toLowerCase().indexOf(q) !== -1 ||
|
|
(p.id || '').toLowerCase().indexOf(q) !== -1;
|
|
});
|
|
},
|
|
selectProvider(id) {
|
|
this.selectedProviderId = id;
|
|
this.keyForm(id);
|
|
},
|
|
llmShortStatus(p) {
|
|
if (!p) return '';
|
|
if (p.id === 'offline') return 'mock';
|
|
if (p.verified) return 'actif';
|
|
if (p.configured) return 'à tester';
|
|
return '—';
|
|
},
|
|
// Set the verified/last_error state on the shared provider entry + its keyForm
|
|
_setProviderState(id, ok, error) {
|
|
var p = this.providerById(id);
|
|
var f = this.keyForm(id);
|
|
if (p) {
|
|
p.verified = !!ok;
|
|
p.last_error = ok ? '' : (error || '');
|
|
p.functional = p.id === 'offline' ? true : !!ok;
|
|
if (!ok) { p.configured = p.has_key || !p.requires_key; }
|
|
}
|
|
f.last_error = ok ? '' : (error || '');
|
|
this.syncLlmStatus();
|
|
},
|
|
syncLlmStatus() {
|
|
var list = this.llmProviders || [];
|
|
var active = list.filter(p => p.verified && p.id !== 'offline');
|
|
this.llmActiveCount = active.length;
|
|
var def = list.find(p => p.id === this.llmDefaultProvider) || null;
|
|
this.llmDefaultFunctional = !!(def && def.functional && def.id !== 'offline');
|
|
if (active.length) {
|
|
if (this.llmDefaultFunctional && def) {
|
|
this.llmSummaryTitle = 'L\u2019agent est prêt à l\u2019emploi';
|
|
this.llmSummaryDesc = 'Le fournisseur par défaut « ' + def.name + ' » est activé et fonctionnel : vos conversations l\u2019utiliseront automatiquement.';
|
|
} else {
|
|
this.llmSummaryTitle = this.llmActiveCount + ' fournisseur(s) fonctionnel(s) pour votre compte';
|
|
this.llmSummaryDesc = 'Choisissez-en un dans le sélecteur du panneau Agent (🤖 en bas à droite) pour chaque conversation' +
|
|
(this.userIsAdmin ? ' — ou activez le fournisseur par défaut de l\u2019espace ci-dessous.' : '.');
|
|
}
|
|
} else {
|
|
this.llmSummaryTitle = 'Agent en mode hors-ligne (mock)';
|
|
this.llmSummaryDesc = 'Aucun fournisseur activé et fonctionnel. Enregistrez une clé ci-dessous puis lancez « Tester » : dès le test réussi, le fournisseur et ses modèles apparaissent dans le panneau Agent.';
|
|
}
|
|
},
|
|
llmBadge(p) {
|
|
if (!p) return '';
|
|
if (p.id === 'offline') return 'Toujours dispo (mock)';
|
|
if (p.verified) return p.source === 'global' ? 'Actif (défaut espace)' : 'Activé et fonctionnel';
|
|
if (p.source === 'global') return 'Défaut espace — à tester (admin)';
|
|
if (!p.requires_key) return 'Local — à tester';
|
|
if (p.configured) return 'Configuré — à tester';
|
|
return 'Non configuré';
|
|
},
|
|
llmBadgeClass(p) {
|
|
if (!p) return 'off';
|
|
if (p.id === 'offline') return 'off';
|
|
if (p.verified) return 'ok';
|
|
if (p.configured) return 'warn';
|
|
return 'off';
|
|
},
|
|
globalNeedsKey() {
|
|
var cur = this.providerById(this.llmForm.provider);
|
|
return !!(cur && cur.requires_key);
|
|
},
|
|
llmGlobalBadge() {
|
|
var cur = this.providerById(this.llmForm.provider);
|
|
if (!cur) return '—';
|
|
if (cur.id === 'offline') return 'Mode hors-ligne';
|
|
if (cur.id !== this.llmDefaultProvider) return 'Changement non enregistré';
|
|
if (cur.verified) return 'Actif pour tous';
|
|
if (this.llmHasKey) return 'Configuré — à tester';
|
|
return 'Non configuré';
|
|
},
|
|
llmGlobalBadgeClass() {
|
|
var cur = this.providerById(this.llmForm.provider);
|
|
if (!cur || cur.id === 'offline') return 'off';
|
|
if (cur.id !== this.llmDefaultProvider) return 'warn';
|
|
if (cur.verified) return 'ok';
|
|
return 'off';
|
|
},
|
|
async loadUserLlm() {
|
|
try {
|
|
var r = await fetch('/api/agent/providers');
|
|
var d = await r.json();
|
|
this.llmProviders = d.providers || [];
|
|
this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
|
|
var kr = await fetch('/api/agent/keys');
|
|
var kd = await kr.json();
|
|
var keys = kd.keys || [];
|
|
this.llmProviders.forEach(p => {
|
|
var k = keys.find(x => x.provider === p.id);
|
|
var f = this.keyForm(p.id);
|
|
// Pré-remplit l'URL avec celle réellement utilisée par l'application
|
|
// (base par défaut du fournisseur) tant qu'aucune URL personnalisée n'est stockée.
|
|
f.api_base = (k && k.api_base) || p.base_url || '';
|
|
f.model = (k && k.default_model) || (p.default_model) || '';
|
|
f.models = (k && k.models) || [];
|
|
f.has_key = !!(k && k.has_key);
|
|
f.last_error = (k && k.last_error) || p.last_error || '';
|
|
});
|
|
if (!this.selectedProviderId || !this.providerById(this.selectedProviderId)) {
|
|
var pool = this.userProviders();
|
|
var pick = pool.find(x => x.verified) || pool.find(x => x.configured) || pool[0];
|
|
this.selectedProviderId = pick ? pick.id : null;
|
|
}
|
|
if (this.selectedProviderId) this.keyForm(this.selectedProviderId);
|
|
this.syncLlmStatus();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async loadProviderModels(id) {
|
|
var f = this.keyForm(id);
|
|
if (f.modelLoading) return;
|
|
f.modelLoading = true; f.msg = ''; f.ok = false;
|
|
try {
|
|
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
|
method: 'POST',
|
|
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
|
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
|
});
|
|
var d = await r.json();
|
|
if (d.ok) {
|
|
f.models = d.models || [];
|
|
if (f.models.length) {
|
|
if (f.models.indexOf(f.model) === -1) f.model = f.models[0];
|
|
f.msg = f.models.length + ' modèles chargés depuis le fournisseur';
|
|
f.ok = true;
|
|
// Une récupération réussie avec la clé stockée prouve la connexion.
|
|
if (!f.api_key) this._setProviderState(id, true, '');
|
|
} else {
|
|
f.msg = 'Connexion OK, mais aucun modèle renvoyé par le fournisseur';
|
|
}
|
|
} else {
|
|
f.msg = 'Erreur: ' + (d.error || 'échec de connexion');
|
|
}
|
|
} catch { f.msg = 'Erreur réseau'; }
|
|
finally { f.modelLoading = false; }
|
|
},
|
|
async saveUserKey(id) {
|
|
var f = this.keyForm(id);
|
|
f.saving = true; f.msg = ''; f.ok = false;
|
|
try {
|
|
var body = {api_key: f.api_key, api_base: f.api_base, default_model: f.model};
|
|
if (f.models && f.models.length) body.models = f.models;
|
|
var r = await fetch('/api/agent/keys/' + id, {
|
|
method: 'PUT',
|
|
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
|
body: JSON.stringify(body)
|
|
});
|
|
var d = await r.json();
|
|
if (r.ok) {
|
|
f.has_key = !!d.has_key;
|
|
f.api_key = '';
|
|
if (f.has_key || !this.globalNeedsKeyFor(id)) {
|
|
f.msg = 'Clé enregistrée — test de connexion en cours…';
|
|
f.ok = true;
|
|
await this.testUserKey(id);
|
|
} else {
|
|
f.msg = 'Enregistré pour ' + id + ' — aucune clé stockée';
|
|
f.ok = true;
|
|
}
|
|
var p = this.providerById(id);
|
|
if (p) { p.has_key = f.has_key; p.configured = f.has_key || !p.requires_key; }
|
|
} else {
|
|
f.msg = d.detail || 'Erreur lors de l\'enregistrement';
|
|
}
|
|
} catch { f.msg = 'Erreur réseau'; }
|
|
finally { f.saving = false; }
|
|
},
|
|
globalNeedsKeyFor(id) {
|
|
var p = this.providerById(id);
|
|
return !!(p && p.requires_key);
|
|
},
|
|
async testUserKey(id) {
|
|
var f = this.keyForm(id);
|
|
if (f.testing) return;
|
|
f.testing = true; f.msg = ''; f.ok = false;
|
|
try {
|
|
var body = {api_base: f.api_base, model: f.model};
|
|
if (f.api_key) body.api_key = f.api_key;
|
|
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
|
method: 'POST',
|
|
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
|
body: JSON.stringify(body)
|
|
});
|
|
var d = await r.json();
|
|
if (d.ok) {
|
|
this._setProviderState(id, true, '');
|
|
f.msg = '✓ ' + id + ' fonctionnel' + (d.model ? ' (' + d.model + ')' : '') + (d.reply ? ' — ' + d.reply : '');
|
|
f.ok = true;
|
|
if (f.models.indexOf(d.model) === -1 && d.model) f.models.push(d.model);
|
|
} else {
|
|
this._setProviderState(id, false, d.error || 'échec de connexion');
|
|
f.msg = '✗ Test échoué : ' + (d.error || 'échec de connexion');
|
|
}
|
|
} catch {
|
|
this._setProviderState(id, false, 'Erreur réseau');
|
|
f.msg = 'Erreur réseau';
|
|
}
|
|
finally { f.testing = false; }
|
|
},
|
|
async deleteUserKey(id) {
|
|
var p = this.providerById(id);
|
|
var pname = (p && p.name) || id;
|
|
if (!confirm('Supprimer la clé API pour ' + pname + ' ?\nLe fournisseur ne sera plus disponible dans le panneau Agent.')) return;
|
|
var f = this.keyForm(id);
|
|
f.deleting = true; f.msg = ''; f.ok = false;
|
|
try {
|
|
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
|
|
var d = await r.json();
|
|
if (r.ok) {
|
|
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
|
var p = this.providerById(id);
|
|
if (p) { p.has_key = false; p.configured = !p.requires_key; }
|
|
this._setProviderState(id, false, '');
|
|
f.msg = 'Clé supprimée pour ' + id + ' — le fournisseur n\u2019est plus disponible dans le panneau Agent.';
|
|
f.ok = true;
|
|
} else {
|
|
f.msg = d.detail || 'Erreur lors de la suppression';
|
|
}
|
|
} catch { f.msg = 'Erreur réseau'; }
|
|
finally { f.deleting = false; }
|
|
},
|
|
async loadLlmConfig() {
|
|
try {
|
|
var r = await fetch('/api/agent/providers');
|
|
var d = await r.json();
|
|
this.llmProviders = d.providers || [];
|
|
this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
|
|
this.llmHasKey = !!d.has_api_key;
|
|
var defp = this.providerById(d.provider || 'offline');
|
|
// Affiche l'URL réellement utilisée par l'application (base par défaut
|
|
// du fournisseur) quand aucune URL personnalisée n'est encore stockée.
|
|
this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '',
|
|
api_base: d.api_base || (defp && defp.base_url) || ''};
|
|
this.applyLlmModels();
|
|
this.syncLlmStatus();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
applyLlmModels() {
|
|
var p = this.providerById(this.llmForm.provider);
|
|
this.llmProviderModels = p ? (p.models || []) : [];
|
|
if (this.llmProviderModels.length && this.llmProviderModels.indexOf(this.llmForm.model) === -1) {
|
|
this.llmForm.model = (p && p.default_model) || this.llmProviderModels[0];
|
|
}
|
|
},
|
|
onLlmProviderChange() {
|
|
this.applyLlmModels();
|
|
var cur = this.providerById(this.llmForm.provider);
|
|
if (cur) {
|
|
this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
|
|
// Affiche l'URL utilisée par l'application pour ce fournisseur.
|
|
this.llmForm.api_base = cur.base_url || '';
|
|
}
|
|
},
|
|
async saveLlmConfig() {
|
|
this.llmSaving = true;
|
|
this.llmMsg = ''; this.llmMsgOk = false;
|
|
try {
|
|
var r = await this.adminFetch('/api/agent/providers', {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model,
|
|
api_key: this.llmForm.api_key, api_base: this.llmForm.api_base})
|
|
});
|
|
var d = await r.json();
|
|
if (r.ok) {
|
|
this.llmHasKey = !!d.has_api_key;
|
|
this.llmDefaultProvider = d.provider || this.llmDefaultProvider;
|
|
this.llmMsgOk = true;
|
|
this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model +
|
|
(d.available ? ' — lancez « Tester la connexion » pour l\u2019activer pour tous.' : ' — clé manquante pour ce provider');
|
|
// Re-évalue le badge du fournisseur par défaut localement.
|
|
var cur = this.providerById(d.provider);
|
|
if (cur) { cur.configured = !!d.has_api_key || !cur.requires_key; }
|
|
this.llmForm.api_key = '';
|
|
this.syncLlmStatus();
|
|
} else {
|
|
this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement';
|
|
}
|
|
} catch { this.llmMsg = 'Erreur réseau'; }
|
|
finally { this.llmSaving = false; }
|
|
},
|
|
async resetGlobalLlm() {
|
|
if (!confirm("Réinitialiser le fournisseur par défaut à « hors-ligne » ?\nTous les utilisateurs perdront l'accès au fournisseur IA actuel.")) return;
|
|
this.llmSaving = true;
|
|
this.llmMsg = ''; this.llmMsgOk = false;
|
|
try {
|
|
var r = await this.adminFetch('/api/agent/providers', {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify({provider: 'offline', model: '', api_key: '', api_base: ''})
|
|
});
|
|
var d = await r.json();
|
|
if (r.ok) {
|
|
this.llmForm = {provider: 'offline', model: '', api_key: '', api_base: ''};
|
|
this.llmHasKey = false;
|
|
this.llmDefaultProvider = 'offline';
|
|
this.llmProviderModels = [];
|
|
this.llmMsgOk = true;
|
|
this.llmMsg = 'Fournisseur réinitialisé : mode hors-ligne actif.';
|
|
this.syncLlmStatus();
|
|
} else {
|
|
this.llmMsg = d.detail || 'Erreur lors de la réinitialisation';
|
|
}
|
|
} catch { this.llmMsg = 'Erreur réseau'; }
|
|
finally { this.llmSaving = false; }
|
|
},
|
|
async testLlm() {
|
|
if (!this.llmForm.provider) return;
|
|
this.llmTesting = true;
|
|
this.llmTestMsg = 'Test en cours…'; this.llmTestOk = false;
|
|
try {
|
|
var r = await this.adminFetch('/api/agent/providers/test', {
|
|
method: 'POST',
|
|
headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model,
|
|
api_key: this.llmForm.api_key, api_base: this.llmForm.api_base})
|
|
});
|
|
var d = await r.json();
|
|
this.llmTestOk = !!d.ok;
|
|
this.llmTestMsg = d.ok
|
|
? ('✓ ' + (d.model || '') + ' fonctionnel' + (d.reply ? ' — ' + d.reply : ''))
|
|
: ('✗ ' + (d.error || 'échec de connexion'));
|
|
// Reflète l'état sur le fournisseur courant du formulaire global.
|
|
var cur = this.providerById(this.llmForm.provider);
|
|
if (cur && this.llmForm.provider === this.llmDefaultProvider) {
|
|
this._setProviderState(this.llmForm.provider, d.ok, d.error || '');
|
|
if (d.ok) cur.verified_model = d.model || '';
|
|
}
|
|
} catch { this.llmTestOk = false; this.llmTestMsg = 'Erreur réseau'; }
|
|
finally { this.llmTesting = false; }
|
|
},
|
|
getCompletedLlmTest() { return this.llmTestMsg; },
|
|
|
|
async saveAccount() {
|
|
this.accountSaving = true;
|
|
this.accountMsg = '';
|
|
var body = {};
|
|
var f = this.accountForm;
|
|
if (f.full_name !== this.userInfo.full_name) body.full_name = f.full_name;
|
|
if (f.login !== this.userInfo.login) body.login = f.login;
|
|
if (f.email !== this.userInfo.email) body.email = f.email;
|
|
if (f.password) body.password = f.password;
|
|
if (Object.keys(body).length === 0) { this.accountSaving = false; return; }
|
|
try {
|
|
var r = await fetch('/api/settings/account', {
|
|
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify(body)
|
|
});
|
|
var d = await r.json();
|
|
if (r.ok) {
|
|
this.userInfo = {login: d.user.login, full_name: d.user.full_name, email: d.user.email};
|
|
this.accountForm.password = '';
|
|
this.accountMsg = '✓ Saved!';
|
|
setTimeout(() => { this.accountMsg = ''; }, 3000);
|
|
} else {
|
|
this.accountMsg = '✗ ' + (d.error || 'Error');
|
|
}
|
|
} catch { this.accountMsg = '✗ Network error'; }
|
|
this.accountSaving = false;
|
|
},
|
|
|
|
async uploadAvatar(ev) {
|
|
var file = ev.target.files[0];
|
|
if (!file) return;
|
|
var form = new FormData();
|
|
form.append('file', file);
|
|
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': getCsrf()}, method: 'POST', body: form });
|
|
if (r.ok) {
|
|
var d = await r.json();
|
|
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
|
this.avatarColor = '';
|
|
}
|
|
},
|
|
|
|
async selectAvatarColor(color) {
|
|
this.avatarColor = color;
|
|
var r = await fetch('/api/settings/avatar-color', {
|
|
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
|
body: JSON.stringify({color: color})
|
|
});
|
|
if (r.ok) { this.avatarUrl = ''; }
|
|
},
|
|
|
|
applyTheme() {
|
|
localStorage.setItem('fd_theme', this.theme);
|
|
document.documentElement.setAttribute('data-theme', this.theme);
|
|
},
|
|
|
|
saveDefaultView() {
|
|
localStorage.setItem('fd_default_view', this.defaultView);
|
|
},
|
|
async loadGiteaStatus() {
|
|
try {
|
|
var r = await fetch('/api/gitea/status');
|
|
var d = await r.json();
|
|
this.giteaLinked = d.linked || false;
|
|
} catch { this.giteaLinked = false; }
|
|
},
|
|
|
|
async disconnectGitea() {
|
|
if (!confirm('Disconnect Gitea? You can reconnect anytime.')) return;
|
|
try {
|
|
var r = await fetch('/api/gitea/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
|
if (r.ok) { this.giteaLinked = false; }
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async loadGithubStatus() {
|
|
try {
|
|
var r = await fetch('/api/github/status');
|
|
var d = await r.json();
|
|
this.githubLinked = d.linked || false;
|
|
} catch { this.githubLinked = false; }
|
|
},
|
|
async disconnectGithub() {
|
|
if (!confirm('Disconnect GitHub? You can reconnect anytime.')) return;
|
|
try {
|
|
var r = await fetch('/api/github/disconnect', { method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
|
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 {
|
|
var r = await fetch('/workspace/automations', {credentials:'same-origin'});
|
|
var d = await r.json();
|
|
this.automations = d.automations || [];
|
|
} catch { this.automations = []; }
|
|
},
|
|
async loadCollectionsForForm() {
|
|
try {
|
|
var r = await fetch('/db/api', {credentials:'same-origin'});
|
|
var d = await r.json();
|
|
this.globalCollections = d.collections || [];
|
|
} catch { this.globalCollections = []; }
|
|
},
|
|
async saveAutomation() {
|
|
if (!this.af.name.trim()) { window.showToast && window.showToast('Nom requis', 'error'); return; }
|
|
try { JSON.parse(this.af.condition_json || '[]'); } catch { window.showToast && window.showToast('Conditions JSON invalide', 'error'); return; }
|
|
try { JSON.parse(this.af.actions_json || '[]'); } catch { window.showToast && window.showToast('Actions JSON invalide', 'error'); return; }
|
|
var payload = {
|
|
name: this.af.name,
|
|
trigger_type: this.af.trigger_type,
|
|
event: this.af.event,
|
|
cron_expression: this.af.cron_expression,
|
|
collection_id: this.af.collection_id ? parseInt(this.af.collection_id, 10) : null,
|
|
condition_json: this.af.condition_json,
|
|
actions_json: this.af.actions_json,
|
|
enabled: true
|
|
};
|
|
var url = '/workspace/automations' + (this.editAutomationId ? '/' + this.editAutomationId : '');
|
|
var method = this.editAutomationId ? 'PUT' : 'POST';
|
|
try {
|
|
var r = await fetch(url, { method: method, credentials:'same-origin',
|
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify(payload) });
|
|
var d = await r.json();
|
|
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Échec', 'error'); return; }
|
|
window.showToast && window.showToast(this.editAutomationId ? 'Automation mise à jour' : 'Automation créée');
|
|
this.cancelEditAutomation();
|
|
await this.loadAutomations();
|
|
} catch { window.showToast && window.showToast('Erreur réseau', 'error'); }
|
|
},
|
|
editAutomation(a) {
|
|
this.editAutomationId = a.id;
|
|
this.af = {
|
|
name: a.name || '',
|
|
trigger_type: a.trigger_type || 'event',
|
|
event: a.event || 'page.created',
|
|
cron_expression: a.cron_expression || '*/15 * * * *',
|
|
collection_id: a.collection_id ? String(a.collection_id) : '',
|
|
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) {
|
|
try {
|
|
var row = this.automations.find(function(x){ return x.id === id; });
|
|
if (!row) return;
|
|
var payload = {
|
|
name: row.name,
|
|
trigger_type: row.trigger_type || 'event',
|
|
event: row.event || 'page.created',
|
|
cron_expression: row.cron_expression || '',
|
|
collection_id: row.collection_id || null,
|
|
condition_json: row.condition_json || '[]',
|
|
actions_json: row.actions_json || '[]',
|
|
enabled: !currentlyEnabled
|
|
};
|
|
var r = await fetch('/workspace/automations/' + id, { method:'PUT', credentials:'same-origin',
|
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify(payload) });
|
|
if (r.ok) await this.loadAutomations();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async runAutomation(id) {
|
|
try {
|
|
this.autoRuns = [];
|
|
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: 'running', detail: '…' });
|
|
var r = await fetch('/workspace/automations/' + id + '/run', { method:'POST', credentials:'same-origin',
|
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
|
|
var d = await r.json();
|
|
this.autoRuns = [];
|
|
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: d.status || 'fired', detail: d.detail || '' });
|
|
window.showToast && window.showToast('Résultat : ' + (d.status || '') + ' — ' + (d.detail || ''));
|
|
await this.loadAutomations();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async deleteAutomation(id) {
|
|
if (!confirm('Supprimer cette automation ?')) return;
|
|
try {
|
|
var r = await fetch('/workspace/automations/' + id, { method:'DELETE', credentials:'same-origin',
|
|
headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
|
if (r.ok) await this.loadAutomations();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
// ── v5.2.0 API tokens ──
|
|
async loadApiTokens() {
|
|
try {
|
|
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': getCsrf()}, credentials:'same-origin'});
|
|
var d = await r.json();
|
|
this.apiTokens = d.tokens || [];
|
|
} catch { 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: {'X-CSRF-Token': getCsrf(), '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 { 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 { 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 { /* volontaire */ }
|
|
},
|
|
// ── 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 { 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 { /* volontaire */ }
|
|
},
|
|
// ── v6.2.0 Web Clipper ──
|
|
async loadClipperDevices() {
|
|
try {
|
|
var r = await fetch('/api/v2/web-clipper/devices', {credentials:'same-origin'});
|
|
var d = await r.json();
|
|
this.clipperDevices = d.devices || [];
|
|
} catch { this.clipperDevices = []; }
|
|
},
|
|
async revokeClipperDevice(d) {
|
|
if (!confirm('Révoquer l\'extension « ' + (d.device_name || d.device_id) + ' » ?')) return;
|
|
try {
|
|
var r = await fetch('/api/v2/web-clipper/devices/' + d.id, {method:'DELETE', credentials:'same-origin', headers: {'X-CSRF-Token': this.getCsrfToken()}});
|
|
if (r.ok) await this.loadClipperDevices();
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async testClip() {
|
|
this.clipTesting = true; this.clipTestMsg = ''; this.clipTestOk = false;
|
|
var url = (this.clipTestUrl || '').trim();
|
|
if (!url) { this.clipTestMsg = 'URL requise'; this.clipTesting = false; return; }
|
|
try {
|
|
var r = await fetch('/api/v2/web-clipper/clip', {method:'POST', credentials:'same-origin',
|
|
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify({url: url, title: 'Test clipper — ' + url, content: '<html><body><h1>Test clip</h1><p>Contenu de test depuis Settings.</p></body></html>', content_type: 'article', device_id: 'settings-test'})});
|
|
var d = await r.json();
|
|
if (r.ok) { this.clipTestOk = true; this.clipTestMsg = '✓ Page créée : #' + d.page_id + ' — ' + d.title; await this.loadClipperDevices(); }
|
|
else this.clipTestMsg = d.detail || 'Erreur';
|
|
} catch { this.clipTestMsg = 'Erreur réseau'; }
|
|
finally { this.clipTesting = false; }
|
|
},
|
|
// ── v6.7.0 SSO / Enterprise (admin) ──
|
|
ssoStatusTitle() {
|
|
if (!this.ssoCfg.id && this.ssoSource !== 'env') return '';
|
|
if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)';
|
|
return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'));
|
|
},
|
|
ssoStatusDesc() {
|
|
var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect';
|
|
return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé');
|
|
},
|
|
ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; },
|
|
async copySsoMetadata() {
|
|
try {
|
|
await navigator.clipboard.writeText(this.ssoMetadataUrl());
|
|
if (typeof window.showToast === 'function') window.showToast('SP metadata URL copiée');
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async loadSsoConfig() {
|
|
this.ssoMsg = '';
|
|
try {
|
|
var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'});
|
|
if (r.ok) {
|
|
var d = await r.json();
|
|
this.ssoSource = d.source || 'db';
|
|
this.ssoProvisioned = d.provisioned_users || 0;
|
|
if (d.configured === false) {
|
|
this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''});
|
|
} else {
|
|
this.ssoCfg = {
|
|
id: d.id || null,
|
|
provider_type: d.provider_type || 'saml',
|
|
name: d.name || '',
|
|
entity_id: d.entity_id || '',
|
|
sso_url: d.sso_url || '',
|
|
slo_url: d.slo_url || '',
|
|
x509_certificate: d.x509_certificate || '',
|
|
issuer_url: d.issuer_url || '',
|
|
client_id: d.client_id || '',
|
|
client_secret: '',
|
|
client_secret_set: !!d.client_secret_set,
|
|
scope: d.scope || 'openid profile email',
|
|
attribute_mapping: d.attribute_mapping || {},
|
|
auto_provision: !!d.auto_provision,
|
|
sso_only: !!d.sso_only,
|
|
sign_requests: !!d.sign_requests,
|
|
default_workspace_id: d.default_workspace_id || ''
|
|
};
|
|
this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2);
|
|
this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; });
|
|
}
|
|
}
|
|
} catch { /* volontaire */ }
|
|
try {
|
|
var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'});
|
|
if (w.ok) {
|
|
var wd = await w.json();
|
|
this.ssoWorkspaces = wd.workspaces || [];
|
|
this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned;
|
|
}
|
|
} catch { /* volontaire */ }
|
|
},
|
|
async saveSsoConfig() {
|
|
this.ssoMsg = '';
|
|
var amap = {};
|
|
try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
|
|
catch { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; }
|
|
if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) {
|
|
this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return;
|
|
}
|
|
this.ssoSaving = true;
|
|
var payload = Object.assign({}, this.ssoCfg, {
|
|
attribute_mapping: amap,
|
|
groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }),
|
|
auto_provision: this.ssoCfg.auto_provision ? 1 : 0,
|
|
sso_only: this.ssoCfg.sso_only ? 1 : 0,
|
|
sign_requests: this.ssoCfg.sign_requests ? 1 : 0,
|
|
default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null
|
|
});
|
|
delete payload.id;
|
|
try {
|
|
var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
|
var d = null; try { d = await r.json(); } catch { /* volontaire */ }
|
|
if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; }
|
|
else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); }
|
|
} catch { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
|
finally { this.ssoSaving = false; }
|
|
},
|
|
async disableSso() {
|
|
if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return;
|
|
this.ssoMsg = '';
|
|
try {
|
|
var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'});
|
|
if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); }
|
|
else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; }
|
|
} catch { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
|
},
|
|
async syncSsoGroups() {
|
|
this.ssoMsg = '';
|
|
try {
|
|
var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
|
|
var d = null; try { d = await r.json(); } catch { /* volontaire */ }
|
|
if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); }
|
|
else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; }
|
|
} catch { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
|
},
|
|
addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); },
|
|
removeSsoGroup(i) { this.ssoGroups.splice(i, 1); },
|
|
// ── 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 { 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 { this.backupMsg = 'Erreur réseau'; }
|
|
finally { this.backupRunning = false; }
|
|
},
|
|
closeSettings() {
|
|
// v7.69.4 — « ça ferme mais ça reste ouvert » quand Settings est ouvert
|
|
// depuis le menu utilisateur : `shouldIntercept()` accepte `/settings`,
|
|
// donc ce clic passe par fdLoad() (htmx + history.pushState) SANS
|
|
// rechargement de page. L'ancien history.back() déclenchait alors un
|
|
// popstate → swap PARTIEL de .main-wrapper, et le panneau `position:
|
|
// fixed` restait peint au-dessus. Depuis /workspaces c'est un
|
|
// chargement complet, d'où l'absence de symptôme.
|
|
// → navigation explicite et déterministe : une seule requête, tout le
|
|
// document est détruit, aucun chemin ne peut se terminer sans bouger.
|
|
var prev = document.referrer;
|
|
if (window.opener && !window.opener.closed) {
|
|
// fermeture au mieux — on ne peut PAS vérifier qu'elle a réussi,
|
|
// donc on enchaîne toujours sur la navigation ci-dessous.
|
|
try { window.opener.location.reload(); } catch (e) { /* opener cross-origin */ }
|
|
try { window.close(); } catch (e) { /* ignoré */ }
|
|
}
|
|
var back = '/workspaces';
|
|
if (prev && prev.indexOf(location.origin) === 0 && prev.indexOf('/settings') < 0) {
|
|
back = prev.split('#')[0];
|
|
}
|
|
location.replace(back + (back.indexOf('?') >= 0 ? '&' : '?') + 'ts=' + Date.now());
|
|
},
|
|
};
|
|
}
|
|
}
|