closeSettings() now: - Refreshes the parent page (history.back + reload) - Handles tab navigation between settings sections - Falls back to /workspaces redirect if no history
829 lines
40 KiB
HTML
829 lines
40 KiB
HTML
{% extends "base.html" %}
|
||
{% block page_title %}Settings{% endblock %}
|
||
{% block title_prefix %}Settings{% endblock %}
|
||
{% block page_icon %}⚙️{% endblock %}
|
||
|
||
{% block topbar %}
|
||
<header class="topbar">
|
||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
</button>
|
||
<div class="topbar-left">
|
||
<span class="page-title-topbar">⚙️ Settings</span>
|
||
</div>
|
||
</header>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.5);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:0 16px 48px rgba(0,0,0,.6);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:#fff;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 #333;}
|
||
.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:#4DA3F5;}
|
||
.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:#0F7B6C;}
|
||
.status-dot.inactive{background:#E03E3E;}
|
||
|
||
/* 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 & 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">
|
||
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:4px;">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:#0F7B6C;"></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:#E03E3E;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:#0F7B6C;">✅ 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:#0F7B6C;">✅ 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 & 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') || 'dark',
|
||
// 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);
|
||
if (this.theme === 'light') {
|
||
document.documentElement.style.setProperty('--bg-primary', '#FFFFFF');
|
||
document.documentElement.style.setProperty('--bg-secondary', '#F7F6F3');
|
||
document.documentElement.style.setProperty('--bg-tertiary', '#EDECE9');
|
||
document.documentElement.style.setProperty('--text', '#37352F');
|
||
document.documentElement.style.setProperty('--text-secondary', '#6B6966');
|
||
document.documentElement.style.setProperty('--text-dim', '#9B9A97');
|
||
document.documentElement.style.setProperty('--border', '#E0DFDC');
|
||
document.documentElement.style.setProperty('--border-strong', '#CCCBC8');
|
||
} else {
|
||
document.documentElement.style.removeProperty('--bg-primary');
|
||
document.documentElement.style.removeProperty('--bg-secondary');
|
||
document.documentElement.style.removeProperty('--bg-tertiary');
|
||
document.documentElement.style.removeProperty('--text');
|
||
document.documentElement.style.removeProperty('--text-secondary');
|
||
document.documentElement.style.removeProperty('--text-dim');
|
||
document.documentElement.style.removeProperty('--border');
|
||
document.documentElement.style.removeProperty('--border-strong');
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
}
|
||
},
|
||
|
||
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 %}
|