Files
flowdeck/app/templates/settings.html
T
bruno 32c81f156d
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Skipped
feat(settings): bouton "Réinitialiser" fournisseur IA + correctif JS (page complète)
- Nouveau bouton « 🗑 Réinitialiser » dans Réglages → Agent & IA (réservé aux
  admins) : bascule le fournisseur par défaut sur « hors-ligne » et efface la
  clé API/base/état vérifié stockés (clear_keys dans set_llm_config).
- Confirmation avant suppression des clés API utilisateur (deleteUserKey).
- Correctif : chaîne JS de confirm("…") sur deux lignes provoquait une erreur
  de syntaxe → le script alpine:init ne s'exécutait pas et la page paramètres
  restait figée sur « Agent & AI » ; la chaîne est désormais sur une ligne.
2026-09-07 20:48:25 -04:00

1358 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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;}
</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-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==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; 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 &amp; 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>
</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>
</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>
<!-- Admin: Users & Roles -->
<div x-show="activeSection==='admin-users'">
<h2>Users &amp; 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">Login history and security events.</p>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>IP Address</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
<template x-for="e in auditEntries" :key="e.id">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
<td>
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
</td>
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
</tr>
</template>
<tr x-show="auditEntries.length === 0">
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; 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>&nbsp;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).</p>
<div class="prov-grid">
<template x-for="p in userProviders()" :key="p.id">
<div class="prov-card">
<div class="prov-card-head">
<span class="prov-card-name" x-text="p.name"></span>
<span class="llm-badge" :class="llmBadgeClass(p)" x-text="llmBadge(p)"></span>
</div>
<div class="prov-card-sub" x-show="keyForm(p.id).last_error" style="color:var(--danger);" x-text="'Dernier test : ' + keyForm(p.id).last_error"></div>
<div class="prov-field-row">
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(p.id).has_key ? '(enregistrée — laisser vide pour conserver)' : ''"></span></div>
<div class="pw-wrapper" style="max-width:100%;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible" style="top:50%;">
<span x-show="keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
<span x-show="!keyForm(p.id).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(p.id).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(p.id).model" style="box-sizing:border-box;">
<template x-for="m in keyModels(p.id)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-actions">
<button class="btn btn-primary" @click="saveUserKey(p.id)" :disabled="keyForm(p.id).saving" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).saving">{{ fd_icon("save",14) }} Enregistrer &amp; tester</span>
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
</button>
<button class="btn" @click="testUserKey(p.id)" :disabled="keyForm(p.id).testing" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).testing">🔌 Tester</span>
<span x-show="keyForm(p.id).testing">Test…</span>
</button>
<button class="btn" @click="loadProviderModels(p.id)" :disabled="keyForm(p.id).modelLoading" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).modelLoading">{{ fd_icon("refresh",14) }} Modèles</span>
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
</button>
<button class="btn" @click="deleteUserKey(p.id)" :disabled="keyForm(p.id).deleting" x-show="keyForm(p.id).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
</div>
<div class="prov-msg">
<span x-show="keyForm(p.id).msg" x-text="keyForm(p.id).msg" :style="{color: keyForm(p.id).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
</div>
</template>
</div>
<div class="llm-hint">
💡 <b>Comment activer un fournisseur ?</b> Enregistrez votre clé puis cliquez « Enregistrer &amp; 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>
document.addEventListener('alpine:init', function() {
Alpine.data('settingsInit', function() {
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},
// 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: [],
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: {},
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}, d.prefs || {});
} 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 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() {
try {
var r = await this.adminFetch('/api/admin/audit?limit=200');
var d = await r.json();
this.auditEntries = d.entries || [];
} catch(e) { this.auditEntries = []; }
},
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');
},
// 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 || '';
});
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 pname = id.charAt(0).toUpperCase() + id.slice(1);
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) {}
},
closeSettings() {
// Refresh parent page after settings changes, then navigate back
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 %}