Files
flowdeck/app/templates/settings.html
T
bruno 34368a4946
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped
feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
2026-09-05 11:47:58 -04:00

1111 lines
55 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);}
</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" 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 &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">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 %}