1111 lines
55 KiB
HTML
1111 lines
55 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);}
|
||
</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 & 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>
|
||
</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" x-text="pwVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></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 & 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 & IA</h2>
|
||
<p class="section-desc">Sauvegardez vos propres clés API par fournisseur : l'agent FlowDeck utilisera la clé du provider sélectionné dans chaque conversation. Les modèles disponibles sont chargés automatiquement depuis le fournisseur.</p>
|
||
|
||
<div class="setting-group">
|
||
<h3>Mes fournisseurs</h3>
|
||
<template x-for="p in llmProviders" :key="p.id">
|
||
<div style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:10px;">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||
<strong style="flex:1;font-size:13px;" x-text="p.name"></strong>
|
||
<span x-show="keyForm(p.id).has_key" style="font-size:11px;color:var(--toast-success-bg);">✓ clé enregistrée</span>
|
||
<span x-show="!keyForm(p.id).has_key" style="font-size:11px;color:var(--text-dim);">aucune clé enregistrée</span>
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">
|
||
Clé API
|
||
<span style="font-size:11px;color:var(--text-dim);">(laisser vide pour conserver l'existante)</span>
|
||
</div>
|
||
<div class="pw-wrapper" style="max-width:340px;">
|
||
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
|
||
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;">
|
||
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible"
|
||
x-text="keyForm(p.id).keyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">URL API (optionnelle — Ollama, proxy OpenAI-compatible…)</div>
|
||
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="max-width:340px;">
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Modèle par défaut</div>
|
||
<select class="settings-input" x-model="keyForm(p.id).model" style="max-width:340px;">
|
||
<template x-for="m in keyModels(p.id)" :key="m">
|
||
<option :value="m" x-text="m"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">
|
||
<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) }} Charger les modèles</span>
|
||
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
|
||
</button>
|
||
<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</span>
|
||
<span x-show="keyForm(p.id).saving">Enregistrement…</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 style="margin-top:8px;font-size:12px;">
|
||
<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>
|
||
<p style="font-size:12px;color:var(--text-dim);margin-top:6px;">
|
||
Sans clé enregistrée pour le provider choisi, l'agent utilise la configuration globale par défaut (ou le mode « offline mock »).
|
||
Activez un provider / modèle dans chaque conversation via les sélecteurs du panneau Agent (🤖 en bas à droite) ou la commande <code>/provider</code> et <code>/model</code>.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="setting-group" x-show="userIsAdmin">
|
||
<h3>Configuration globale (défaut pour tous les utilisateurs)</h3>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Provider</div>
|
||
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:300px;">
|
||
<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="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Modèle</div>
|
||
<select class="settings-input" x-model="llmForm.model" style="max-width:300px;">
|
||
<template x-for="m in llmProviderModels" :key="m">
|
||
<option :value="m" x-text="m"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">Clé API <span x-show="llmHasKey" style="font-size:11px;color:var(--toast-success-bg);margin-left:6px;">✓ configurée</span></div>
|
||
<div class="pw-wrapper" style="max-width:300px;">
|
||
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-… (laisser vide pour conserver l'existante)" style="padding-right:36px;">
|
||
<button type="button" class="pw-toggle" @click="llmKeyVisible=!llmKeyVisible" x-text="llmKeyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||
<div class="setting-label">URL API (optionnelle)</div>
|
||
<input type="text" class="settings-input" x-model="llmForm.api_base" placeholder="https://… (Ollama, proxy OpenAI-compatible…)" style="max-width:300px;">
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:14px;">
|
||
<button class="btn btn-primary" @click="saveLlmConfig()" :disabled="llmSaving" style="font-size:13px;">
|
||
<span x-show="!llmSaving">{{ fd_icon("save",14) }} Save configuration</span>
|
||
<span x-show="llmSaving">Saving…</span>
|
||
</button>
|
||
<button class="btn" @click="testLlm()" :disabled="llmTesting" style="font-size:13px;">🔌 Test connection</button>
|
||
</div>
|
||
<div style="margin-top:12px;font-size:13px;">
|
||
<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:12px;color:var(--text-dim);margin-top:14px;">
|
||
Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau).
|
||
</p>
|
||
</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,
|
||
// 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, deleting:false, msg:'', ok:false};
|
||
}
|
||
return this.keyForms[id];
|
||
},
|
||
keyModels(id) {
|
||
var p = this.llmProviders.find(x => x.id === 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;
|
||
},
|
||
async loadUserLlm() {
|
||
try {
|
||
var r = await fetch('/api/agent/providers');
|
||
var d = await r.json();
|
||
this.llmProviders = d.providers || [];
|
||
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);
|
||
this.keyForm(p.id).api_base = (k && k.api_base) || '';
|
||
this.keyForm(p.id).model = (k && k.default_model) || (p.default_model) || '';
|
||
this.keyForm(p.id).models = (k && k.models) || [];
|
||
this.keyForm(p.id).has_key = !!(k && k.has_key);
|
||
});
|
||
} 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';
|
||
f.ok = true;
|
||
} else {
|
||
f.msg = '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 = '';
|
||
f.msg = 'Enregistré pour ' + id + (f.has_key ? ' — clé active ✓' : ' — aucune clé stockée');
|
||
f.ok = true;
|
||
} else {
|
||
f.msg = d.detail || 'Erreur lors de l\'enregistrement';
|
||
}
|
||
} catch(e) { f.msg = 'Erreur réseau'; }
|
||
finally { f.saving = false; }
|
||
},
|
||
async deleteUserKey(id) {
|
||
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.msg = 'Clé supprimée pour ' + id;
|
||
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.llmHasKey = !!d.has_api_key;
|
||
this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''};
|
||
this.applyLlmModels();
|
||
} catch(e) {}
|
||
},
|
||
applyLlmModels() {
|
||
var p = this.llmProviders.find(x => x.id === 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(); },
|
||
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.llmMsgOk = true;
|
||
this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model + (d.available ? ' — disponible ✓' : ' — clé manquante pour ce provider');
|
||
this.llmForm.api_key = '';
|
||
} else {
|
||
this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement';
|
||
}
|
||
} 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 || '') + ' : ' + (d.reply || 'réponse reçue'))
|
||
: ('✗ ' + (d.error || 'échec de connexion'));
|
||
} 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 %}
|