4 blocs interpolés extraits avec la recette de la 2a (config JSON inline +
JS statique, substitutions sur le CORPS du bloc) :
- local_workspace.html → local_workspace.js (2 031 L, lw-config :
current_folder_id, workspace_id)
- settings.html → settings.js (1 093 L, st-config : avatar, user
full_name/login/email, is_admin (bool), auth_method — 2 routes rendent ce
template, expressions « or "" » préservées pour les valeurs Undefined)
- _page_editor_realtime.html → page_editor_realtime.js (531 L, rt-config :
SELF id/login/full_name/color)
- board.html → board.js (146 L, bd-config : owner/repo/initial_view)
BONUS sécurité : les valeurs passent par |tojson (échappement JSON explicite)
au lieu d'être interpolées dans des strings JS. Tags : config JSON (nonce
conservé) + <script src> ?v={{ asset_version }} ; loaders JSON.parse en tête
(try/catch → {}). Correctif sur le loader (accolade try en trop, caught par
node --check avant tout commit).
Cumul A27 : 10 560 L extraites (13 904 → 3 344 restantes, -76 %).
Reste structurel : base 1 338 ({% block %}/{% for %}) + database_table 1 323
(if/else) + 279 warnings eslint (12 fichiers, 0 erreur).
suite **1089/1089** · ruff OK · node --check ×4 vert · docs à jour
1197 lines
76 KiB
HTML
1197 lines
76 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 type="application/json" id="st-config" nonce="{{ csp_nonce() }}">{{ {"avatar_url": avatar_url or "", "avatar_color": avatar_color or "#3A3A3A", "full_name": user.full_name or "", "login": user.login or "", "email": user.email or "", "is_admin": True if user.get("is_admin") else False, "auth_method": auth_method} | tojson }}</script>
|
||
<script data-cfasync="false" src="/static/js/settings.js?v={{ asset_version }}"></script>
|
||
{% endblock %}
|