Files
flowdeck/static/js/settings.js
T
bruno 7468e497c6
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 15m40s
FlowDeck CI / docker (push) Skipped
fix: le bouton close de Settings ferme en un clic (v7.69.4)
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
2026-10-08 22:02:08 -04:00

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());
},
};
}
}