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', 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: '

Test clip

Contenu de test depuis Settings.

', 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() { if (window.opener && !window.opener.closed) { window.opener.location.reload(); window.close(); return; } // If navigated directly to settings, redirect to workspaces (forces refresh) if (!document.referrer || document.referrer === window.location.href) { window.location.href = '/workspaces?ts=' + Date.now(); return; } // Go back and force refresh var prev = document.referrer; if (prev && prev.includes('/settings')) { // Came from another settings tab — go back two steps then refresh history.go(-2); setTimeout(function() { window.location.reload(); }, 100); } else { history.back(); setTimeout(function() { window.location.reload(); }, 100); } }, }; } }