- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
2289 lines
123 KiB
HTML
2289 lines
123 KiB
HTML
{% extends "base.html" %}
|
||
{% block page_title %}Settings{% endblock %}
|
||
{% block title_prefix %}Settings{% endblock %}
|
||
{% block page_icon %}{{ fd_icon("settings",18) }}{% endblock %}
|
||
|
||
{% block topbar %}
|
||
{% set page_icon = "settings" %}
|
||
{% set page_title = "Settings" %}
|
||
{% include '_header.html' %}
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
||
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||
.settings-nav-item:hover{background:var(--bg-hover);}
|
||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||
.setting-group{margin-bottom:28px;}
|
||
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
|
||
.setting-row:last-child{border-bottom:none;}
|
||
.setting-label{font-size:14px;color:var(--text);}
|
||
.setting-desc{font-size:12px;color:var(--text-dim);margin-top:2px;}
|
||
.setting-control{flex-shrink:0;margin-left:16px;}
|
||
|
||
/* Avatar */
|
||
.avatar-section{display:flex;align-items:center;gap:16px;padding:12px 0;}
|
||
.avatar-preview{width:64px;height:64px;border-radius:50%;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:var(--text);overflow:hidden;flex-shrink:0;}
|
||
.avatar-preview img{width:100%;height:100%;object-fit:cover;}
|
||
.avatar-upload-label{cursor:pointer;font-size:13px;color:var(--accent);padding:6px 12px;border:1px solid var(--accent);border-radius:6px;transition:all 150ms;}
|
||
.avatar-upload-label:hover{background:rgba(35,131,226,.1);}
|
||
.avatar-upload-input{display:none;}
|
||
|
||
/* Avatar color swatches */
|
||
.avatar-color-swatch{width:40px;height:40px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--text-primary);cursor:pointer;border:2px solid transparent;transition:all 120ms;}
|
||
.avatar-color-swatch:hover{transform:scale(1.1);}
|
||
.avatar-color-swatch.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.4);}
|
||
|
||
/* Tags */
|
||
.tag-mgmt-item{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--bg-tertiary);border-radius:8px;margin-bottom:4px;}
|
||
.tag-mgmt-item .tag-color-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;}
|
||
.tag-name{flex:1;font-size:13px;color:var(--text);}
|
||
.tag-count{font-size:12px;color:var(--text-dim);}
|
||
.new-tag-form{display:flex;align-items:center;gap:10px;margin-top:8px;padding:8px 10px;background:var(--bg-tertiary);border:1px dashed var(--border);border-radius:8px;}
|
||
.new-tag-form input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;}
|
||
.color-swatch{width:22px;height:22px;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:all 120ms;}
|
||
.color-swatch:hover{transform:scale(1.15);}
|
||
.color-swatch.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);}
|
||
|
||
/* Admin tables */
|
||
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid var(--border);}
|
||
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||
.admin-table th{background:var(--bg-tertiary);color:var(--text-dim);font-weight:500;padding:8px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}
|
||
.admin-table td{padding:8px 12px;border-top:1px solid var(--border);color:var(--text);}
|
||
.admin-table tr:hover td{background:var(--bg-secondary);}
|
||
.stat-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center;}
|
||
.stat-value{font-size:22px;font-weight:700;color:var(--accent);}
|
||
.stat-label{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.5px;}
|
||
.role-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;}
|
||
.role-admin{background:rgba(35,131,226,.2);color:var(--accent);}
|
||
.role-user{background:rgba(120,119,116,.2);color:var(--text-dim);}
|
||
.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:middle;}
|
||
.status-dot.active{background:var(--toast-success-bg);}
|
||
.status-dot.inactive{background:var(--danger);}
|
||
|
||
/* Close button */
|
||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
|
||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||
|
||
/* Agent & IA — status summary + provider cards */
|
||
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
|
||
.llm-summary-icon{font-size:26px;line-height:1;flex-shrink:0;}
|
||
.llm-summary-body{flex:1;min-width:220px;}
|
||
.llm-summary-title{font-size:14px;font-weight:600;color:var(--text);}
|
||
.llm-summary-desc{font-size:12.5px;color:var(--text-dim);margin-top:2px;}
|
||
.llm-summary-side{display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
||
.llm-badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 10px;border-radius:99px;white-space:nowrap;}
|
||
.llm-badge.ok{background:rgba(46,160,67,.15);color:#2ea043;}
|
||
.llm-badge.warn{background:rgba(217,115,13,.16);color:#d9730d;}
|
||
.llm-badge.err{background:rgba(224,62,62,.14);color:#e03e3e;}
|
||
.llm-badge.off{background:rgba(120,119,116,.15);color:var(--text-dim);}
|
||
.prov-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
|
||
.prov-card{border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:4px;}
|
||
.prov-card-head{display:flex;align-items:center;gap:8px;margin-bottom:2px;flex-wrap:wrap;}
|
||
.prov-card-name{font-size:13.5px;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;}
|
||
.prov-card-sub{font-size:11.5px;color:var(--text-dim);margin-bottom:8px;line-height:1.4;}
|
||
.prov-field-row{display:flex;flex-direction:column;gap:4px;margin-top:2px;}
|
||
.prov-field-label{font-size:12px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||
.prov-field-label .muted{font-size:10.5px;color:var(--text-dim);font-weight:400;}
|
||
.prov-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
|
||
.prov-card input.settings-input,.prov-card select.settings-input{width:100%;box-sizing:border-box;}
|
||
.prov-card .pw-wrapper{width:100%;}
|
||
.prov-card .pw-wrapper .settings-input{padding-right:36px;}
|
||
.prov-msg{font-size:12px;margin-top:8px;min-height:0;word-break:break-word;}
|
||
.llm-hint{font-size:12px;color:var(--text-secondary);background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.18);border-radius:8px;padding:8px 12px;margin-top:14px;line-height:1.5;}
|
||
|
||
/* Agent & IA — provider master/detail (scales to dozens of providers) */
|
||
.llm-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
||
.llm-search{position:relative;flex:1;min-width:180px;}
|
||
.llm-search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-dim);pointer-events:none;}
|
||
.llm-search input{padding-left:32px;box-sizing:border-box;}
|
||
.llm-count{font-size:11px;color:var(--text-dim);white-space:nowrap;text-transform:uppercase;letter-spacing:.4px;}
|
||
.llm-layout{display:grid;grid-template-columns:264px 1fr;gap:14px;align-items:start;}
|
||
.llm-list{border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);max-height:480px;overflow-y:auto;padding:6px;display:flex;flex-direction:column;gap:2px;}
|
||
.llm-list-item{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:background 100ms;border:1px solid transparent;}
|
||
.llm-list-item:hover{background:var(--bg-hover);}
|
||
.llm-list-item.active{background:rgba(35,131,226,.14);border-color:rgba(35,131,226,.3);}
|
||
.llm-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--text-dim);}
|
||
.llm-dot.ok{background:#2ea043;}
|
||
.llm-dot.warn{background:#d9730d;}
|
||
.llm-dot.err{background:#e03e3e;}
|
||
.llm-list-name{flex:1;min-width:0;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.llm-list-status{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;}
|
||
.llm-list-empty{padding:12px;font-size:12px;color:var(--text-dim);text-align:center;}
|
||
.llm-detail{border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);padding:16px;min-height:260px;}
|
||
.llm-detail-head{display:flex;align-items:center;gap:10px;margin-bottom:4px;flex-wrap:wrap;}
|
||
.llm-detail-name{font-size:15px;font-weight:600;color:var(--text);}
|
||
.llm-detail-base{font-size:11px;color:var(--text-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;margin-bottom:12px;}
|
||
.llm-detail .prov-field-row{margin-top:12px;}
|
||
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
|
||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||
</style>
|
||
|
||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||
<div class="settings-panel" style="position:relative;">
|
||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||
|
||
<!-- Left navigation -->
|
||
<div class="settings-nav">
|
||
<div class="settings-nav-header">Account</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||
<div class="settings-nav-header">Workspace</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||
<div class="settings-nav-header">Features</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||
<!-- Admin nav: only visible to admins -->
|
||
<template x-if="userIsAdmin">
|
||
<div>
|
||
<div class="settings-nav-header">Admin</div>
|
||
<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>
|
||
|
||
<!-- Right content -->
|
||
<div class="settings-content">
|
||
|
||
<!-- Account -->
|
||
<div x-show="activeSection==='account'">
|
||
<h2>My account</h2>
|
||
<p class="section-desc">Manage your profile and preferences.</p>
|
||
<div class="setting-group">
|
||
<h3>Profile</h3>
|
||
<div class="avatar-section">
|
||
<div class="avatar-preview" :style="{background: avatarColor || '#3A3A3A'}">
|
||
<img x-show="avatarUrl" :src="avatarUrl" alt="Avatar">
|
||
<span x-show="!avatarUrl">{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
|
||
</div>
|
||
<div>
|
||
<label class="avatar-upload-label" style="margin-bottom:4px;">
|
||
Upload photo
|
||
<input type="file" class="avatar-upload-input" accept="image/*" @change="uploadAvatar($event)">
|
||
</label>
|
||
<div style="font-size:11px;color:var(--text-dim);margin-top:8px;">JPG, PNG or GIF — max 2MB</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Or pick a color:</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
<template x-for="c in avatarColors" :key="c">
|
||
<div class="avatar-color-swatch" :style="{background: c}"
|
||
:class="{ selected: avatarColor === c }"
|
||
@click="selectAvatarColor(c)">
|
||
<span>{{ user.get('full_name', user.get('login',''))[:1].upper() }}</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Editable fields -->
|
||
<div class="setting-group">
|
||
<h3>Account details</h3>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Username</div>
|
||
<input type="text" class="settings-input" x-model="accountForm.login" style="max-width:300px;">
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Full name</div>
|
||
<input type="text" class="settings-input" x-model="accountForm.full_name" style="max-width:300px;">
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Email</div>
|
||
<input type="email" class="settings-input" x-model="accountForm.email" style="max-width:300px;">
|
||
</div>
|
||
<div style="margin-top:12px;">
|
||
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving" style="width:auto;padding:8px 20px;font-size:13px;">
|
||
<span x-show="!accountSaving">{{ fd_icon("save",14) }} Save changes</span>
|
||
<span x-show="accountSaving">Saving…</span>
|
||
</button>
|
||
<span x-show="accountMsg" x-text="accountMsg" style="margin-left:12px;font-size:13px;color:var(--toast-success-bg);"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Password change -->
|
||
<div class="setting-group">
|
||
<h3>Change password</h3>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">New password</div>
|
||
<div class="pw-wrapper" style="max-width:300px;">
|
||
<input :type="pwVisible ? 'text' : 'password'" class="settings-input" x-model="accountForm.password" placeholder="Leave blank to keep current" style="padding-right:36px;">
|
||
<button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" style="top:50%;">
|
||
<span x-show="pwVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
|
||
<span x-show="!pwVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:12px;">
|
||
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving || !accountForm.password" style="width:auto;padding:8px 20px;font-size:13px;">
|
||
<span x-show="!accountSaving">{{ fd_icon("lock",14) }} Update password</span>
|
||
<span x-show="accountSaving">Saving…</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Preferences -->
|
||
<div class="setting-group">
|
||
<h3>Preferences</h3>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Theme</div>
|
||
<div class="setting-desc">Choose between light and dark appearance</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<select x-model="theme" @change="applyTheme()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;">
|
||
<option value="dark">Dark</option>
|
||
<option value="light">Light</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Default view</div>
|
||
<div class="setting-desc">Workspace view mode when opening a workspace</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<select x-model="defaultView" @change="saveDefaultView()" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;">
|
||
<option value="tree">{{ fd_icon("folder",14) }} Tree</option>
|
||
<option value="list">{{ fd_icon("list",14) }} List</option>
|
||
<option value="details">{{ fd_icon("bar-chart",14) }} Details</option>
|
||
<option value="title">{{ fd_icon("grid",14) }} Title</option>
|
||
<option value="content">{{ fd_icon("align-left",14) }} Content</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notifications -->
|
||
<div x-show="activeSection==='notifications'">
|
||
<h2>Notifications</h2>
|
||
<p class="section-desc">Choose when and how you want to be notified.</p>
|
||
<div class="setting-group">
|
||
<h3>Email notifications</h3>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Comments</div>
|
||
<div class="setting-desc">When someone comments on your pages</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<label class="toggle-switch sm">
|
||
<input type="checkbox" x-model="notifPrefs.comments" @change="saveNotifPrefs()">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Mentions</div>
|
||
<div class="setting-desc">When someone @mentions you</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<label class="toggle-switch sm">
|
||
<input type="checkbox" x-model="notifPrefs.mentions" @change="saveNotifPrefs()">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Reminders</div>
|
||
<div class="setting-desc">Upcoming events & date reminders from your databases</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<label class="toggle-switch sm">
|
||
<input type="checkbox" x-model="notifPrefs.reminders" @change="saveNotifPrefs()">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Assignments</div>
|
||
<div class="setting-desc">When a database row is assigned to you</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<label class="toggle-switch sm">
|
||
<input type="checkbox" x-model="notifPrefs.assignments" @change="saveNotifPrefs()">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="setting-group">
|
||
<h3>Timezone</h3>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Your timezone</div>
|
||
<div class="setting-desc">Used by calendar views (today, week start) and reminder firing. Empty = UTC.</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<select class="settings-input" style="max-width:260px" x-model="userTimezone" @change="saveTimezone()">
|
||
<option value="">UTC</option>
|
||
<template x-for="z in userZones" :key="z">
|
||
<option :value="z" x-text="z"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API tokens -->
|
||
<div x-show="activeSection==='api-tokens'">
|
||
<h2>API tokens</h2>
|
||
<p class="section-desc">Generate personal access tokens for the public API (<code>/api/v1</code>) — e.g. for scripts and external tools.</p>
|
||
<div class="setting-group">
|
||
<h3>New token</h3>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||
<div class="setting-label">Name</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<input type="text" class="settings-input" x-model="tokenName" placeholder="Ex : CI script, monitoring…" style="flex:1;" @keydown.enter="createApiToken()">
|
||
<button class="btn btn-primary" @click="createApiToken()" :disabled="!tokenName.trim()" style="white-space:nowrap;">Generate</button>
|
||
</div>
|
||
</div>
|
||
<template x-if="newToken">
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;background:rgba(46,160,67,.08);border-radius:8px;">
|
||
<div class="setting-label" style="color:var(--toast-success-bg);">Token créé — copiez-le maintenant (affiché une seule fois)</div>
|
||
<div style="display:flex;gap:8px;align-items:center;">
|
||
<code class="settings-input" x-text="newToken.token" style="flex:1;font-family:var(--font-mono);font-size:12px;user-select:all;"></code>
|
||
<button class="btn-sm" @click="copyNewToken()">Copy</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="setting-group">
|
||
<h3>Tokens existants <span style="text-transform:none;font-size:11px;" x-text="'(' + apiTokens.length + ')'"></span></h3>
|
||
<div x-show="!apiTokens.length" class="setting-desc" style="padding:8px 0;">Aucun token pour l'instant.</div>
|
||
<template x-for="t in apiTokens" :key="t.id">
|
||
<div class="setting-row">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="setting-label">
|
||
<span x-text="t.name"></span>
|
||
<code style="font-size:11px;color:var(--text-dim);margin-left:8px;" x-text="'fd_' + (t.token_prefix||'').slice(0,12)"></code>
|
||
</div>
|
||
<div class="setting-desc" style="font-size:11px;">
|
||
<span x-text="'Créé le ' + (t.created_at||'').slice(0,16)"></span>
|
||
<template x-if="t.last_used_at"><span x-text="' · utilisé le ' + t.last_used_at.slice(0,16)"></span></template>
|
||
</div>
|
||
</div>
|
||
<div class="setting-control" style="display:flex;gap:6px;align-items:center;">
|
||
<span class="llm-badge" :class="t.revoked ? 'off' : 'ok'" x-text="t.revoked ? 'Révoqué' : 'Actif'"></span>
|
||
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeApiToken(t)" x-show="!t.revoked">Révoquer</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sessions -->
|
||
<div x-show="activeSection==='sessions'">
|
||
<h2>Active sessions</h2>
|
||
<p class="section-desc">Appareils actuellement connectés à votre compte. Révoquez une session pour la déconnecter immédiatement.</p>
|
||
<div class="setting-group">
|
||
<div x-show="!sessions.length" class="setting-desc" style="padding:8px 0;">Aucune session enregistrée.</div>
|
||
<template x-for="s in sessions" :key="s.id">
|
||
<div class="setting-row">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="setting-label">
|
||
<span x-text="s.is_current ? 'Cet appareil' : 'Autre appareil'"></span>
|
||
<span class="llm-badge" :class="s.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="s.revoked ? 'Révoquée' : (s.is_current ? 'Active' : 'Active')"></span>
|
||
</div>
|
||
<div class="setting-desc" style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:520px;">
|
||
<span x-text="s.user_agent || 'Agent inconnu'"></span>
|
||
</div>
|
||
<div class="setting-desc" style="font-size:11px;">
|
||
<span x-text="'IP: ' + (s.ip_address || '—')"></span>
|
||
<span x-text="' · ' + (s.last_seen_at||'').slice(0,16)"></span>
|
||
</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeSession(s)" x-show="!s.revoked" x-text="s.is_current ? 'Se déconnecter' : 'Révoquer'"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Extensions (Web Clipper) -->
|
||
<div x-show="activeSection==='extensions'">
|
||
<h2>Extensions</h2>
|
||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||
<div class="setting-group">
|
||
<h3>Web Clipper</h3>
|
||
<p style="font-size:12px;color:var(--text-dim);line-height:1.5;margin-bottom:12px;">
|
||
Install the <a href="/extensions" style="color:var(--accent);">browser extension</a> (Chrome/Firefox/Edge). After install, use <b>Verify & Connect</b> once or paste an <b>API token</b> in the extension popup.
|
||
</p>
|
||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||
<a href="/extensions" class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Get extension</a>
|
||
<a href="/static/extension/flowdeck-clipper.zip" download class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Download .zip</a>
|
||
</div>
|
||
<div x-show="!clipperDevices.length" class="setting-desc" style="padding:8px 0;">No extension connected yet — clip your first page to appear here.</div>
|
||
<template x-for="d in clipperDevices" :key="d.id">
|
||
<div class="setting-row">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="setting-label">
|
||
<span x-text="(d.device_name || d.extension_name || 'Clipper') + ' — ' + d.device_id.slice(0,24)"></span>
|
||
<span class="llm-badge" :class="d.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="d.revoked ? 'Révoqué' : 'Actif'"></span>
|
||
<span class="llm-badge warn" style="margin-left:6px;" x-text="d.extension_name"></span>
|
||
</div>
|
||
<div class="setting-desc" style="font-size:11px;">
|
||
<span x-text="'Connecté: ' + (d.created_at||'').slice(0,16)"></span>
|
||
<span x-show="d.last_used_at" x-text="' · Dernier: ' + d.last_used_at.slice(0,16)"></span>
|
||
<span x-text="' · ' + d.clips_count + ' clipp(s)'"></span>
|
||
<span x-show="d.last_clip_at" x-text="' · Dernier clip: ' + d.last_clip_at.slice(0,16)"></span>
|
||
</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeClipperDevice(d)" x-show="!d.revoked">Révoquer</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="setting-group">
|
||
<h3>Tester le clipper (sans extension)</h3>
|
||
<p style="font-size:12px;color:var(--text-dim);">Envoyez un clip тест direct via l'API :</p>
|
||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||
<input class="settings-input" x-model="clipTestUrl" placeholder="https://example.com/article" style="flex:1;">
|
||
<button class="btn btn-primary" @click="testClip()" :disabled="clipTesting" x-text="clipTesting ? 'Envoi…' : 'Clip URL'"></button>
|
||
</div>
|
||
<div x-show="clipTestMsg" style="margin-top:8px;font-size:12px;" :style="{color: clipTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}" x-text="clipTestMsg"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Workspace -->
|
||
<div x-show="activeSection==='workspace'">
|
||
<h2>Workspace</h2>
|
||
<p class="section-desc">General workspace settings.</p>
|
||
<div class="setting-group">
|
||
<h3>General</h3>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Workspace name</div>
|
||
<div class="setting-desc">{{ workspace_name }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tags -->
|
||
<div x-show="activeSection==='tags'">
|
||
<h2>Tags</h2>
|
||
<p class="section-desc">Manage all tags across your workspace.</p>
|
||
<div class="setting-group">
|
||
<h3>All tags</h3>
|
||
<template x-for="tag in allTags" :key="tag.id">
|
||
<div class="tag-mgmt-item">
|
||
<div class="tag-color-dot" :style="{background: tag.color}"></div>
|
||
<span class="tag-name"
|
||
x-text="tag.name"
|
||
@dblclick="renameTag(tag)"
|
||
title="Double-click to rename"
|
||
style="cursor:pointer;"></span>
|
||
<span class="tag-count" x-text="tag.count + ' items'"></span>
|
||
<input type="color" :value="tag.color" @input="updateTagColor(tag.id, $event.target.value)" style="width:24px;height:24px;border:none;cursor:pointer;background:none;padding:0;" title="Change color">
|
||
<button class="tag-chip rm-btn" @click="deletingTag=tag" title="Delete">×</button>
|
||
</div>
|
||
</template>
|
||
<div class="new-tag-form">
|
||
<template x-for="c in presetColors" :key="c">
|
||
<div class="color-swatch" :class="{ selected: newTagColor === c }" :style="{background: c}" @click="newTagColor=c"></div>
|
||
</template>
|
||
<input type="text" placeholder="New tag..." x-model="newTagName" @keydown.enter="createTag()">
|
||
<button class="btn btn-primary" @click="createTag()" :disabled="!newTagName.trim()" style="padding:4px 12px;font-size:12px;">Add</button>
|
||
</div>
|
||
</div>
|
||
<!-- Delete confirmation modal -->
|
||
<div class="modal-overlay" x-show="deletingTag" @click.self="deletingTag=null" @keydown.escape="deletingTag=null" style="z-index:1000;">
|
||
<div class="modal-box" style="max-width:360px;">
|
||
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||
</span>
|
||
</p>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||
<button class="btn" @click="deletingTag=null">Cancel</button>
|
||
<button class="btn btn-danger" @click="confirmDeleteTag()">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Rename modal -->
|
||
<div class="modal-overlay" x-show="renamingTag" @click.self="renamingTag=null" @keydown.escape="renamingTag=null" style="z-index:1000;">
|
||
<div class="modal-box" style="max-width:360px;">
|
||
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||
<div style="display:flex;gap:8px;align-items:center;">
|
||
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||
<input type="text" class="settings-input" x-model="renameValue"
|
||
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||
style="flex:1;" autofocus>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
|
||
<button class="btn" @click="renamingTag=null">Cancel</button>
|
||
<button class="btn btn-primary" @click="confirmRenameTag()" :disabled="!renameValue.trim()">Rename</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Features -->
|
||
<div x-show="activeSection==='features'">
|
||
<h2>Features</h2>
|
||
<p class="section-desc">Integrations and feature toggles.</p>
|
||
<div class="setting-group">
|
||
<h3>Integrations</h3>
|
||
<!-- Gitea -->
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
|
||
<div class="setting-desc">
|
||
<span x-show="giteaLinked">Connected to git.dracodev.net</span>
|
||
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
|
||
</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<span x-show="giteaLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span>
|
||
<button x-show="giteaLinked && authMethod!=='gitea'" class="btn-sm" style="color:var(--danger);" @click="disconnectGitea()">Disconnect</button>
|
||
<a x-show="!giteaLinked" class="btn-sm" href="/auth/login?provider=gitea&mode=link" style="text-decoration:none;color:var(--accent);">{{ fd_icon("link",14) }} Connect</a>
|
||
</div>
|
||
</div>
|
||
<!-- GitHub -->
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">GitHub <span x-show="authMethod==='github'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
|
||
<div class="setting-desc">
|
||
<span x-show="githubLinked">Connected to github.com</span>
|
||
<span x-show="!githubLinked">Connect your GitHub account to access repositories</span>
|
||
</div>
|
||
</div>
|
||
<div class="setting-control">
|
||
<span x-show="githubLinked" style="font-size:12px;color:var(--toast-success-bg);">✅ Active</span>
|
||
<button x-show="githubLinked && authMethod!=='github'" class="btn-sm" style="color:var(--danger);" @click="disconnectGithub()">Disconnect</button>
|
||
<a x-show="!githubLinked" class="btn-sm" href="/auth/login?provider=github&mode=link" style="text-decoration:none;color:var(--accent);">{{ fd_icon("link",14) }} Connect</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Automations -->
|
||
<div x-show="activeSection==='automations'">
|
||
<h2>Automations</h2>
|
||
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
||
|
||
<!-- New / Edit form -->
|
||
<div class="setting-group">
|
||
<h3 x-text="editAutomationId ? 'Modifier' : 'Nouvelle automation'"></h3>
|
||
<div class="setting-row">
|
||
<div>
|
||
<div class="setting-label">Nom</div>
|
||
<input class="settings-input" style="width:100%;" type="text" x-model="af.name" placeholder="Ex : Notifier le statut Done">
|
||
</div>
|
||
</div>
|
||
<div class="setting-row" style="flex-wrap:wrap;gap:10px;">
|
||
<div>
|
||
<div class="setting-label">Déclencheur</div>
|
||
<select class="settings-input" style="min-width:180px;" x-model="af.trigger_type" @change="af.event='page.created'">
|
||
<option value="event">Événement (base de données)</option>
|
||
<option value="cron">Planifié (cron)</option>
|
||
<option value="button">Bouton cliquable</option>
|
||
</select>
|
||
</div>
|
||
<div x-show="af.trigger_type==='event'">
|
||
<div class="setting-label">Événement</div>
|
||
<select class="settings-input" style="min-width:200px;" x-model="af.event">
|
||
<option value="page.created">Page créée</option>
|
||
<option value="page.updated">Page modifiée</option>
|
||
<option value="page.deleted">Page supprimée</option>
|
||
<option value="collection.created">Base créée</option>
|
||
<option value="collection.updated">Base modifiée</option>
|
||
<option value="collection.deleted">Base supprimée</option>
|
||
</select>
|
||
</div>
|
||
<div x-show="af.trigger_type==='cron'">
|
||
<div>
|
||
<div class="setting-label">Expression cron</div>
|
||
<input class="settings-input" style="min-width:180px;" type="text" x-model="af.cron_expression" placeholder="*/15 * * * * | @hourly | @daily">
|
||
</div>
|
||
</div>
|
||
<div x-show="af.trigger_type==='event'">
|
||
<div>
|
||
<div class="setting-label">Base (optionnel)</div>
|
||
<select class="settings-input" style="min-width:180px;" x-model="af.collection_id">
|
||
<option value="">Toutes les bases</option>
|
||
<template x-for="c in globalCollections" :key="c.id">
|
||
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||
<div class="setting-label">Actions (JSON)</div>
|
||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- List -->
|
||
<div class="setting-group">
|
||
<h3>Mes automatisations <span style="font-size:11px;color:var(--text-tertiary);" x-text="automations.length + ' règle(s)'"></span></h3>
|
||
<div x-show="!automations.length" class="setting-desc" style="padding:8px 0;">Aucune automation pour l'instant.</div>
|
||
<template x-for="a in automations" :key="a.id">
|
||
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="setting-label">
|
||
<span x-text="'⚡ '+a.name"></span>
|
||
<span x-show="!a.enabled" style="font-size:10px;background:#666;color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">OFF</span>
|
||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'('+a.trigger_type+' · '+(a.event||a.cron_expression||'')+')'"></span>
|
||
</div>
|
||
<div class="setting-desc" style="font-size:11px;">
|
||
<span x-text="a.run_count + ' exécution(s)'"></span>
|
||
<span x-show="a.last_run_at" x-text="' · dernière : '+a.last_run_at"></span>
|
||
</div>
|
||
</div>
|
||
<div class="setting-control" style="gap:6px;">
|
||
<button class="btn-sm" @click="runAutomation(a.id)">▶ Lancer</button>
|
||
<button class="btn-sm" @click="toggleAutomation(a.id, a.enabled)"><span x-text="a.enabled ? 'Désactiver' : 'Activer'"></span></button>
|
||
<button class="btn-sm" @click="editAutomation(a)">✎</button>
|
||
<button class="btn-sm" style="color:var(--danger);" @click="deleteAutomation(a.id)">🗑</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Runs history -->
|
||
<div class="setting-group" x-show="autoRuns.length">
|
||
<h3>Historique d'exécutions</h3>
|
||
<template x-for="r in autoRuns" :key="r.id">
|
||
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||
<div style="flex:1;min-width:0;">
|
||
<span style="font-size:12px;" x-text="r.created_at"></span>
|
||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'['+r.trigger_source+']'"></span>
|
||
</div>
|
||
<span style="font-size:11px;" x-text="r.detail" :style="r.status==='error' ? 'color:var(--danger);' : 'color:var(--text-secondary);'"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Admin: Users & Roles -->
|
||
<div x-show="activeSection==='admin-users'">
|
||
<h2>Users & Roles</h2>
|
||
<p class="section-desc">Manage user accounts, roles, and permissions.</p>
|
||
|
||
<!-- Quick Stats -->
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">
|
||
<div class="stat-card">
|
||
<div class="stat-value" x-text="adminStats.total_users">—</div>
|
||
<div class="stat-label">Total Users</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" x-text="adminStats.total_workspaces">—</div>
|
||
<div class="stat-label">Workspaces</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" x-text="adminStats.total_files">—</div>
|
||
<div class="stat-label">Files</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" x-text="adminStats.total_mb + ' MB'">— MB</div>
|
||
<div class="stat-label">Storage</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create User Button -->
|
||
<div style="margin-bottom:16px;">
|
||
<button class="btn btn-primary" @click="showCreateUser=!showCreateUser" style="font-size:13px;">+ New User</button>
|
||
</div>
|
||
|
||
<!-- Create User Form -->
|
||
<div x-show="showCreateUser" class="new-tag-form" style="margin-bottom:16px;">
|
||
<input type="text" placeholder="Login" x-model="newUser.login" style="width:120px;">
|
||
<input type="text" placeholder="Full name" x-model="newUser.name" style="width:140px;">
|
||
<input type="email" placeholder="Email" x-model="newUser.email" style="width:160px;">
|
||
<input type="password" placeholder="Password" x-model="newUser.password" style="width:130px;">
|
||
<label style="font-size:11px;color:var(--text-dim);white-space:nowrap;">
|
||
<input type="checkbox" x-model="newUser.is_admin"> Admin
|
||
</label>
|
||
<button class="btn btn-primary" @click="adminCreateUser()" :disabled="!newUser.login || !newUser.password" style="padding:4px 12px;font-size:12px;">Create</button>
|
||
</div>
|
||
|
||
<!-- Users Table -->
|
||
<div class="table-wrap">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>User</th>
|
||
<th>Role</th>
|
||
<th>Status</th>
|
||
<th>Workspaces</th>
|
||
<th>Files</th>
|
||
<th>Folders</th>
|
||
<th>Storage</th>
|
||
<th>Last Login</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="u in adminUsers" :key="u.id">
|
||
<tr>
|
||
<td>
|
||
<div style="font-weight:500;" x-text="u.full_name || u.login"></div>
|
||
<div style="font-size:11px;color:var(--text-dim);" x-text="u.login"></div>
|
||
</td>
|
||
<td>
|
||
<span class="role-badge" :class="u.is_admin ? 'role-admin' : 'role-user'" x-text="u.is_admin ? 'Admin' : 'User'"></span>
|
||
</td>
|
||
<td>
|
||
<span class="status-dot" :class="u.is_active ? 'active' : 'inactive'"></span>
|
||
<span x-text="u.is_active ? 'Active' : 'Inactive'" style="font-size:12px;color:var(--text-dim);"></span>
|
||
</td>
|
||
<td x-text="u.ws_count" style="text-align:center;"></td>
|
||
<td x-text="u.file_count" style="text-align:center;"></td>
|
||
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||
<td>
|
||
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;gap:4px;">
|
||
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Edit User Modal -->
|
||
<div class="modal-overlay" x-show="editingUser" @click.self="editingUser=null" @keydown.escape="editingUser=null" style="z-index:1001;">
|
||
<div class="modal-box" style="max-width:400px;">
|
||
<h3 style="margin:0 0 12px;">Edit User: <span x-text="editUserForm.login"></span></h3>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<input type="text" class="settings-input" placeholder="Full name" x-model="editUserForm.name">
|
||
<input type="email" class="settings-input" placeholder="Email" x-model="editUserForm.email">
|
||
<input type="password" class="settings-input" placeholder="New password (leave blank to keep)" x-model="editUserForm.password">
|
||
<label style="font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px;">
|
||
<input type="checkbox" x-model="editUserForm.is_admin"> Admin role
|
||
</label>
|
||
<label style="font-size:13px;color:var(--text);display:flex;align-items:center;gap:8px;">
|
||
<input type="checkbox" x-model="editUserForm.is_active"> Active account
|
||
</label>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
|
||
<button class="btn" @click="editingUser=null">Cancel</button>
|
||
<button class="btn btn-primary" @click="adminUpdateUser()">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Admin: Audit Log -->
|
||
<div x-show="activeSection==='admin-audit'">
|
||
<h2>Audit Log</h2>
|
||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
||
</template>
|
||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||
<button class="btn" style="font-size:12px;" @click="loadAuditLogs()">Apply</button>
|
||
<span style="flex:1;"></span>
|
||
<a class="btn" style="font-size:12px;" :href="auditCsvUrl()" download>Export CSV</a>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th>
|
||
<th>Source</th>
|
||
<th>Actor</th>
|
||
<th>Action</th>
|
||
<th>Resource</th>
|
||
<th>IP</th>
|
||
<th>Detail</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||
<tr>
|
||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||
<td><span style="font-size:11px;" x-text="e.resource"></span></td>
|
||
<td><code style="font-size:11px;" x-text="e.ip || '—'"></code></td>
|
||
<td style="font-size:11px;color:var(--text-dim);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="e.detail" x-text="e.detail || '—'"></td>
|
||
</tr>
|
||
</template>
|
||
<tr x-show="!auditLogs.length && !auditLoading">
|
||
<td colspan="7" style="text-align:center;color:var(--text-dim);padding:20px;">No audit events yet</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
||
</div>
|
||
<style>
|
||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||
.audit-src-api{background:rgba(76,154,255,.15);color:#4c9aff;}
|
||
.audit-src-permissions{background:rgba(255,176,0,.15);color:#ffb000;}
|
||
.audit-src-sso{background:rgba(0,200,100,.15);color:#00cc66;}
|
||
</style>
|
||
</div>
|
||
|
||
<!-- Admin: Backups -->
|
||
<div x-show="activeSection==='admin-backups'">
|
||
<h2>Backups</h2>
|
||
<p class="section-desc">Sauvegardes automatiques de la base (quotidiennes). Cliquez pour en générer une immédiatement.</p>
|
||
<div class="setting-group">
|
||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
|
||
<button class="btn btn-primary" @click="runBackup()" :disabled="backupRunning" style="font-size:13px;">
|
||
<span x-show="!backupRunning">{{ fd_icon("save",14) }} Lancer une sauvegarde</span>
|
||
<span x-show="backupRunning">Sauvegarde…</span>
|
||
</button>
|
||
<span x-show="backupMsg" x-text="backupMsg" style="font-size:13px;" :style="{color: backupOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="admin-table">
|
||
<thead><tr><th>Fichier</th><th>Taille</th><th>Modifié</th></tr></thead>
|
||
<tbody>
|
||
<template x-for="b in backups" :key="b.filename">
|
||
<tr>
|
||
<td><code style="font-size:12px;" x-text="b.filename"></code></td>
|
||
<td x-text="b.size + ' B'" style="text-align:right;"></td>
|
||
<td x-text="b.modified_at" style="font-size:12px;color:var(--text-dim);"></td>
|
||
</tr>
|
||
</template>
|
||
<tr x-show="!backups.length"><td colspan="3" style="text-align:center;color:var(--text-dim);padding:20px;" x-text="'Aucune sauvegarde — ou dossier '+ '[' + 'backups' + ']' + ' non accessible.'"></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</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>
|
||
<p class="section-desc">Connectez des fournisseurs d'IA pour piloter l'agent FlowDeck. Un fournisseur doit être enregistré <b>et</b> avoir réussi son test de connexion pour devenir disponible dans le panneau Agent (🤖 en bas à droite). Sans fournisseur actif, l'agent fonctionne en mode hors-ligne (mock).</p>
|
||
|
||
<!-- Status summary -->
|
||
<div class="llm-summary">
|
||
<div class="llm-summary-icon">🤖</div>
|
||
<div class="llm-summary-body">
|
||
<div class="llm-summary-title" x-text="llmSummaryTitle"></div>
|
||
<div class="llm-summary-desc" x-text="llmSummaryDesc"></div>
|
||
</div>
|
||
<div class="llm-summary-side" x-show="llmActiveCount">
|
||
<span class="llm-badge ok">✓ <span x-text="llmActiveCount"></span> actif(s)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Admin: workspace default provider -->
|
||
<div class="setting-group" x-show="userIsAdmin">
|
||
<h3>Fournisseur par défaut de l'espace (tous les utilisateurs)</h3>
|
||
<div class="prov-card">
|
||
<div class="prov-card-head">
|
||
<span class="prov-card-name">Configuration globale</span>
|
||
<span class="llm-badge" :class="llmGlobalBadgeClass()" x-text="llmGlobalBadge()"></span>
|
||
</div>
|
||
<div class="prov-field-row" style="margin-top:2px;">
|
||
<div class="prov-field-label">Fournisseur</div>
|
||
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:320px;">
|
||
<template x-for="p in llmProviders" :key="p.id">
|
||
<option :value="p.id" x-text="p.name + (p.requires_key ? ' (clé API requise)' : '')"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<div class="prov-field-row" x-show="llmForm.provider !== 'offline'">
|
||
<div class="prov-field-label">Modèle par défaut</div>
|
||
<select class="settings-input" x-model="llmForm.model" style="max-width:320px;">
|
||
<template x-for="m in llmProviderModels" :key="m">
|
||
<option :value="m" x-text="m"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<div class="prov-field-row" x-show="globalNeedsKey()">
|
||
<div class="prov-field-label">Clé API <span class="muted" x-text="llmHasKey ? '(déjà configurée — laisser vide pour conserver)' : '(requise pour ce fournisseur)'"></span></div>
|
||
<div class="pw-wrapper" style="max-width:320px;">
|
||
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-…" style="padding-right:36px;">
|
||
<button type="button" class="pw-toggle" @click="llmKeyVisible=!llmKeyVisible" style="top:50%;">
|
||
<span x-show="llmKeyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
|
||
<span x-show="!llmKeyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="prov-field-row">
|
||
<div class="prov-field-label">URL API <span class="muted">(optionnelle — Ollama, proxy OpenAI-compatible…)</span></div>
|
||
<input type="text" class="settings-input" x-model="llmForm.api_base" placeholder="https://…" style="max-width:320px;">
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
|
||
<button class="btn btn-primary" @click="saveLlmConfig()" :disabled="llmSaving" style="font-size:13px;">
|
||
<span x-show="!llmSaving">{{ fd_icon("save",14) }} Enregistrer</span>
|
||
<span x-show="llmSaving">Enregistrement…</span>
|
||
</button>
|
||
<button class="btn" @click="testLlm()" :disabled="llmTesting" style="font-size:13px;">
|
||
<span x-show="!llmTesting">🔌 Tester la connexion</span>
|
||
<span x-show="llmTesting">Test…</span>
|
||
</button>
|
||
<button class="btn" @click="resetGlobalLlm()" :disabled="llmSaving" x-show="llmDefaultProvider !== 'offline'" style="font-size:13px;color:var(--danger);">🗑 Réinitialiser</button>
|
||
</div>
|
||
<div style="margin-top:10px;font-size:12.5px;">
|
||
<span x-show="llmMsg" x-text="llmMsg" :style="{color: llmMsgOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||
<span x-show="llmTestMsg" x-text="llmTestMsg" :style="{color: llmTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||
</div>
|
||
<p style="font-size:11.5px;color:var(--text-dim);margin:10px 0 0;">💡 Après un changement de fournisseur ou de clé, lancez « Tester la connexion » : si le test réussit, ce fournisseur devient disponible pour tous dans le panneau Agent.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- My providers (per-user API keys) -->
|
||
<div class="setting-group">
|
||
<h3>Mes fournisseurs</h3>
|
||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi). Sélectionnez un fournisseur dans la liste pour le configurer.</p>
|
||
<div class="llm-toolbar">
|
||
<div class="llm-search">
|
||
{{ fd_icon("search",14) }}
|
||
<input type="text" class="settings-input" x-model="llmSearch" placeholder="Rechercher un fournisseur…">
|
||
</div>
|
||
<span class="llm-count" x-text="filteredProviders().length + ' fournisseurs'"></span>
|
||
</div>
|
||
<div class="llm-layout">
|
||
<div class="llm-list">
|
||
<template x-for="p in filteredProviders()" :key="p.id">
|
||
<div class="llm-list-item" :class="{active: p.id===selectedProviderId}" @click="selectProvider(p.id)">
|
||
<span class="llm-dot" :class="llmBadgeClass(p)"></span>
|
||
<span class="llm-list-name" x-text="p.name"></span>
|
||
<span class="llm-list-status" x-text="llmShortStatus(p)"></span>
|
||
</div>
|
||
</template>
|
||
<div class="llm-list-empty" x-show="!filteredProviders().length">Aucun fournisseur</div>
|
||
</div>
|
||
<div class="llm-detail">
|
||
<template x-if="selectedProvider()">
|
||
<div>
|
||
<div class="llm-detail-head">
|
||
<span class="llm-detail-name" x-text="selectedProvider().name"></span>
|
||
<span class="llm-badge" :class="llmBadgeClass(selectedProvider())" x-text="llmBadge(selectedProvider())"></span>
|
||
</div>
|
||
<div class="llm-detail-base" x-text="keyForm(selectedProviderId).api_base || selectedProvider().base_url || '—'"></div>
|
||
<div class="prov-card-sub" x-show="keyForm(selectedProviderId).last_error" style="color:var(--danger);margin-bottom:0;" x-text="'Dernier test : ' + keyForm(selectedProviderId).last_error"></div>
|
||
<div class="prov-field-row">
|
||
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(selectedProviderId).has_key ? '(enregistrée — laisser vide pour conserver)' : (selectedProvider().requires_key ? '(requise)' : '(optionnelle)')"></span></div>
|
||
<div class="pw-wrapper" style="max-width:100%;">
|
||
<input :type="keyForm(selectedProviderId).keyVisible ? 'text' : 'password'" class="settings-input"
|
||
x-model="keyForm(selectedProviderId).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
|
||
<button type="button" class="pw-toggle" @click="keyForm(selectedProviderId).keyVisible=!keyForm(selectedProviderId).keyVisible" style="top:50%;">
|
||
<span x-show="keyForm(selectedProviderId).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
|
||
<span x-show="!keyForm(selectedProviderId).keyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="prov-field-row">
|
||
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
|
||
<input type="text" class="settings-input" x-model="keyForm(selectedProviderId).api_base" placeholder="https://…" style="box-sizing:border-box;">
|
||
</div>
|
||
<div class="prov-field-row">
|
||
<div class="prov-field-label">Modèle par défaut</div>
|
||
<select class="settings-input" x-model="keyForm(selectedProviderId).model" style="box-sizing:border-box;">
|
||
<template x-for="m in keyModels(selectedProviderId)" :key="m">
|
||
<option :value="m" x-text="m"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<div class="prov-actions">
|
||
<button class="btn btn-primary" @click="saveUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).saving" style="font-size:12px;padding:6px 12px;">
|
||
<span x-show="!keyForm(selectedProviderId).saving">{{ fd_icon("save",14) }} Enregistrer & tester</span>
|
||
<span x-show="keyForm(selectedProviderId).saving">Enregistrement…</span>
|
||
</button>
|
||
<button class="btn" @click="testUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).testing" style="font-size:12px;padding:6px 12px;">
|
||
<span x-show="!keyForm(selectedProviderId).testing">🔌 Tester</span>
|
||
<span x-show="keyForm(selectedProviderId).testing">Test…</span>
|
||
</button>
|
||
<button class="btn" @click="loadProviderModels(selectedProviderId)" :disabled="keyForm(selectedProviderId).modelLoading" style="font-size:12px;padding:6px 12px;">
|
||
<span x-show="!keyForm(selectedProviderId).modelLoading">{{ fd_icon("refresh",14) }} Modèles</span>
|
||
<span x-show="keyForm(selectedProviderId).modelLoading">Chargement…</span>
|
||
</button>
|
||
<button class="btn" @click="deleteUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).deleting" x-show="keyForm(selectedProviderId).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
|
||
</div>
|
||
<div class="prov-msg">
|
||
<span x-show="keyForm(selectedProviderId).msg" x-text="keyForm(selectedProviderId).msg" :style="{color: keyForm(selectedProviderId).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="llm-detail-empty" x-show="!selectedProvider()">Sélectionnez un fournisseur pour le configurer.</div>
|
||
</div>
|
||
</div>
|
||
<div class="llm-hint">
|
||
💡 <b>Comment activer un fournisseur ?</b> Sélectionnez-le, enregistrez votre clé puis cliquez « Enregistrer & tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent.
|
||
Pour Ollama, aucun fournisseur distant : renseignez l'URL locale (ex. <code>http://localhost:11434/v1</code>) puis testez.
|
||
En mode hors-ligne (aucun fournisseur actif), l'agent reste utilisable mais génère du contenu local, sans appel réseau.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script data-cfasync="false">
|
||
function settingsInit() {
|
||
return {
|
||
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: '{{ avatar_url or "" }}',
|
||
avatarColor: '{{ avatar_color or "#3A3A3A" }}',
|
||
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: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
|
||
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
|
||
notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
|
||
userTimezone: '', userZones: [],
|
||
// Auth & Integrations
|
||
authMethod: '{{ auth_method }}',
|
||
giteaLinked: false,
|
||
githubLinked: false,
|
||
// Theme
|
||
theme: localStorage.getItem('fd_theme') || 'light',
|
||
// Account form
|
||
accountForm: {login:'{{ user.login or "" }}', full_name:'{{ user.full_name or "" }}', email:'{{ user.email or "" }}', 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,
|
||
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(e) {}
|
||
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(e) {}
|
||
},
|
||
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(e) {}
|
||
},
|
||
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 toast === 'function') toast('Timezone saved');
|
||
} catch(e) {}
|
||
},
|
||
|
||
async loadTags() {
|
||
try {
|
||
var r = await fetch('/api/settings/tags/all');
|
||
var d = await r.json();
|
||
this.allTags = d.tags || [];
|
||
} catch(e) { this.allTags = []; }
|
||
},
|
||
|
||
async createTag() {
|
||
var n = this.newTagName.trim();
|
||
if (!n) return;
|
||
var r = await fetch('/api/settings/tags', {
|
||
method: 'POST', headers: {'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: {'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, { method: 'DELETE' });
|
||
await this.loadTags();
|
||
},
|
||
|
||
async renameTag(tag) {
|
||
this.renamingTag = tag;
|
||
this.renameValue = tag.name;
|
||
// Focus the input after Alpine renders the modal
|
||
var self = this;
|
||
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'},
|
||
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(e) { this.adminUsers = []; }
|
||
await this.loadAdminStats();
|
||
},
|
||
|
||
async loadAdminStats() {
|
||
try {
|
||
var r = await this.adminFetch('/api/admin/stats');
|
||
this.adminStats = await r.json();
|
||
} catch(e) { 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(e) {
|
||
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(e) { 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(e) {}
|
||
},
|
||
|
||
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(e) {}
|
||
},
|
||
|
||
// ── 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(e) {}
|
||
},
|
||
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: {'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(e) { 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: {'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(e) { 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: {'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(e) {
|
||
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, { 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(e) { 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(e) {}
|
||
},
|
||
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(e) { 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(e) { 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(e) { 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(e) { 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', { 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: {'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(e) { 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(e) {}
|
||
},
|
||
async loadGithubStatus() {
|
||
try {
|
||
var r = await fetch('/api/github/status');
|
||
var d = await r.json();
|
||
this.githubLinked = d.linked || false;
|
||
} catch(e) { 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(e) {}
|
||
},
|
||
async loadAutomations() {
|
||
try { await this.loadCollectionsForForm(); } catch(e) {}
|
||
try {
|
||
var r = await fetch('/workspace/automations', {credentials:'same-origin'});
|
||
var d = await r.json();
|
||
this.automations = d.automations || [];
|
||
} catch(e) { this.automations = []; }
|
||
},
|
||
async loadCollectionsForForm() {
|
||
try {
|
||
var r = await fetch('/db/api', {credentials:'same-origin'});
|
||
var d = await r.json();
|
||
this.globalCollections = d.collections || [];
|
||
} catch(e) { 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(e) { window.showToast && window.showToast('Conditions JSON invalide', 'error'); return; }
|
||
try { JSON.parse(this.af.actions_json || '[]'); } catch(e) { 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(e) { 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 || '[]'
|
||
};
|
||
},
|
||
cancelEditAutomation() {
|
||
this.editAutomationId = null;
|
||
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(e) {}
|
||
},
|
||
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(e) {}
|
||
},
|
||
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(e) {}
|
||
},
|
||
// ── v5.2.0 API tokens ──
|
||
async loadApiTokens() {
|
||
try {
|
||
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
|
||
var d = await r.json();
|
||
this.apiTokens = d.tokens || [];
|
||
} catch(e) { 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: {'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(e) { 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(e) { 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(e) {}
|
||
},
|
||
// ── 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(e) { 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(e) {}
|
||
},
|
||
// ── 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(e) { 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(e) {}
|
||
},
|
||
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(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 {
|
||
var r = await this.adminFetch('/api/settings/backups');
|
||
var d = await r.json();
|
||
this.backups = d.backups || [];
|
||
} catch(e) { 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(e) { 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);
|
||
}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|