feat: v6.7.0 — SSO/SAML + OIDC entreprise (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, SSO only, onglet Settings « SSO / Enterprise », API /api/v2/sso/*, help, migration 23, docs + OpenAPI 439 chemins) · 802 tests verts
This commit is contained in:
@@ -153,6 +153,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -870,6 +871,141 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: SSO / Enterprise (v6.7.0) -->
|
||||
<div x-show="activeSection==='admin-sso'">
|
||||
<h2>SSO / Enterprise</h2>
|
||||
<p class="section-desc">Connectez un IdP d'entreprise (SAML 2.0 ou OpenID Connect). Les comptes sont créés au premier login et les groupes de l'IdP deviennent des rôles workspace.</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="llm-summary" x-show="ssoCfg.id || ssoSource==='env'">
|
||||
<div class="llm-summary-icon">🔐</div>
|
||||
<div class="llm-summary-body">
|
||||
<div class="llm-summary-title" x-text="ssoStatusTitle()"></div>
|
||||
<div class="llm-summary-desc" x-text="ssoStatusDesc()"></div>
|
||||
</div>
|
||||
<div class="llm-summary-side">
|
||||
<span class="llm-badge" :class="ssoSource==='env' ? 'warn' : 'ok'" x-text="ssoSource==='env' ? 'via .env' : 'Actif'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider -->
|
||||
<div class="setting-group">
|
||||
<h3>Fournisseur</h3>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="settings-input" x-model="ssoCfg.provider_type" style="max-width:220px;">
|
||||
<option value="saml">SAML 2.0</option>
|
||||
<option value="oidc">OpenID Connect</option>
|
||||
</select>
|
||||
<input type="text" class="settings-input" placeholder="Nom affiché (ex : Company SSO)" x-model="ssoCfg.name" style="max-width:300px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SAML -->
|
||||
<div class="setting-group" x-show="ssoCfg.provider_type==='saml'">
|
||||
<h3>Configuration SAML</h3>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Entity ID (IdP)</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.entity_id" placeholder="https://idp.example.com/saml/metadata" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Sign-On URL</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.sso_url" placeholder="https://idp.example.com/saml/sso" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Logout URL (optionnel)</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.slo_url" placeholder="https://idp.example.com/saml/slo" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Certificat de signature de l'IdP (PEM)</div>
|
||||
<textarea class="settings-input" rows="6" x-model="ssoCfg.x509_certificate" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Mapping des attributs (JSON)</div>
|
||||
<textarea class="settings-input" rows="4" x-model="ssoAttrJson" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder='{"email":"email","full_name":"displayName","groups":"groups"}'></textarea>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-size:13px;margin-top:12px;">
|
||||
<input type="checkbox" x-model="ssoCfg.sign_requests"> Signer les AuthnRequests / LogoutRequests
|
||||
</label>
|
||||
<div style="margin-top:14px;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<span>SP metadata à donner à l'IdP :</span>
|
||||
<code style="font-size:12px;user-select:all;" x-text="ssoMetadataUrl()"></code>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" @click="copySsoMetadata()">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC -->
|
||||
<div class="setting-group" x-show="ssoCfg.provider_type==='oidc'">
|
||||
<h3>Configuration OpenID Connect</h3>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Issuer URL</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.issuer_url" placeholder="https://idp.example.com/realms/flowdeck" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client ID</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.client_id" placeholder="flowdeck" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client Secret</div>
|
||||
<div class="pw-wrapper" style="max-width:560px;">
|
||||
<input type="password" class="settings-input" x-model="ssoCfg.client_secret"
|
||||
:placeholder="ssoCfg.client_secret_set ? 'Déjà enregistré — laisser vide pour conserver' : 'Client secret'"
|
||||
style="padding-right:36px;">
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Scope</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.scope" placeholder="openid profile email" style="max-width:560px;">
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-top:10px;">L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.</p>
|
||||
</div>
|
||||
|
||||
<!-- Provisioning -->
|
||||
<div class="setting-group">
|
||||
<h3>Provisioning & accès</h3>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
|
||||
<input type="checkbox" x-model="ssoCfg.auto_provision" style="margin-top:3px;">
|
||||
<span><b>Auto-provision</b> — créer le compte au premier login (sinon seuls les comptes existants passent)</span>
|
||||
</label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
|
||||
<input type="checkbox" x-model="ssoCfg.sso_only" style="margin-top:3px;">
|
||||
<span><b>SSO only</b> — désactiver le login local (les administrateurs gardent le leur)</span>
|
||||
</label>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Espace par défaut des utilisateurs SSO</div>
|
||||
<select class="settings-input" x-model="ssoCfg.default_workspace_id" style="max-width:320px;">
|
||||
<option value="">— Aucun —</option>
|
||||
<template x-for="w in ssoWorkspaces" :key="w.id">
|
||||
<option :value="w.id" x-text="w.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group mapping -->
|
||||
<div class="setting-group">
|
||||
<h3>Groupes IdP → rôles workspace</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Groupe SSO</th><th>Rôle</th><th>Espace</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(g, i) in ssoGroups" :key="i">
|
||||
<tr>
|
||||
<td><input class="settings-input" x-model="g.sso_group" placeholder="FlowDeck Admins" style="min-width:170px;"></td>
|
||||
<td>
|
||||
<select class="settings-input" x-model="g.workspace_role" style="min-width:110px;">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="settings-input" x-model="g.workspace_id" style="min-width:150px;">
|
||||
<option value="">Espace par défaut</option>
|
||||
<template x-for="w in ssoWorkspaces" :key="w.id">
|
||||
<option :value="w.id" x-text="w.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</td>
|
||||
<td><button class="btn btn-secondary" style="font-size:12px;padding:4px 8px;" @click="removeSsoGroup(i)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!ssoGroups.length"><td colspan="4" style="text-align:center;color:var(--text-dim);padding:14px;">Aucune règle — les groupes de l'IdP ne modifient aucun rôle.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="font-size:13px;margin-top:10px;" @click="addSsoGroup()">+ Ajouter une règle</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px;">
|
||||
<button class="btn btn-primary" style="font-size:13px;" @click="saveSsoConfig()" :disabled="ssoSaving" x-text="ssoSaving ? 'Enregistrement…' : 'Enregistrer la configuration SSO'"></button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="disableSso()">Désactiver le SSO</button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="syncSsoGroups()">Re-sync des groupes</button>
|
||||
<span x-show="ssoMsg" x-text="ssoMsg" style="font-size:13px;" :style="{color: ssoOk ? 'var(--toast-success-bg, #00CC66)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
<p x-show="ssoSource==='env'" class="section-desc" style="margin-top:10px;">Configuration actuellement lue depuis les variables <code>SSO_*</code> du .env — un enregistrement ici la remplace.</p>
|
||||
</div>
|
||||
|
||||
<!-- Agent & IA: per-user provider keys + admin global default -->
|
||||
<div x-show="activeSection==='llm'">
|
||||
<h2>Agent & IA</h2>
|
||||
@@ -1115,6 +1251,17 @@ function settingsInit() {
|
||||
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();
|
||||
@@ -1935,6 +2082,113 @@ function settingsInit() {
|
||||
} catch(e) { 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 toast === 'function') toast('SP metadata URL copiée');
|
||||
} catch(e) {}
|
||||
},
|
||||
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(e) {}
|
||||
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(e) {}
|
||||
},
|
||||
async saveSsoConfig() {
|
||||
this.ssoMsg = '';
|
||||
var amap = {};
|
||||
try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
|
||||
catch(e) { 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(e) {}
|
||||
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(e) { 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(e) { 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(e) {}
|
||||
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(e) { 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 {
|
||||
|
||||
Reference in New Issue
Block a user