Files
flowdeck/app/templates/settings.html
T
bruno 63773cb904
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
fix: 4 correctifs UX — workspace +, library empty, settings spacing
- Workspaces: bouton bleu '+' maintenant centré, 48px, min-height 140px (plus coupé)
- Library no-workspace: 'Open a Workspace...' avec lien Go to Workspaces au lieu de Loading
- Library: boutons '+ Add new' et 'New page' cachés quand pas de workspace
- Settings: espacement bouton Upload photo (margin-bottom, margin-top augmentés)
- 143 tests passent
2026-07-20 11:21:14 -04:00

804 lines
39 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 %}⚙️{% endblock %}
{% block topbar %}
{% set page_icon = "⚙" %}
{% 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'">👤 My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'">🔔 Notifications</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">📋 General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">🏷️ Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">🧩 Integrations</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()">👥 Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">📋 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">💾 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 ? '🙈' : '👁'" 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">🔒 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">🌳 Tree</option>
<option value="list">📋 List</option>
<option value="details">📊 Details</option>
<option value="title">🏷️ Title</option>
<option value="content">📝 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"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></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"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></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;">
⚠️ 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);">🔗 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);">🔗 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">✏️</button>
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">🗑</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>
</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' }},
// 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},
async init() {
await this.loadTags();
await this.loadGiteaStatus();
await this.loadGithubStatus();
},
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) {}
},
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 %}