feat: administration — users, roles, stats, audit
DB: - login_history table (user_id, ip, user_agent, timestamp) - Migration v2.5: alter users + create login_history Auth: - Premier utilisateur = admin (is_admin=1) - _log_login() après chaque connexion (register, local-login, OAuth) Backend (app/routers/admin.py): - admin_required middleware (vérifie is_admin) - GET /api/admin/users → liste users + stats par user - POST /api/admin/users → créer user - PUT /api/admin/users/:id → modifier (name, email, password, admin, active) - DELETE /api/admin/users/:id → supprimer + cascade - GET /api/admin/stats → totaux globaux - GET /api/admin/audit → historique login Frontend (settings.html): - Nav Admin visible seulement si userIsAdmin - Users & Roles: stats cards, create/edit/delete users, table complète - Audit Log: historique login avec date, IP, user-agent - CSS professionnel: table-wrap, admin-table, stat-card, role-badge, status-dot
This commit is contained in:
+248
-20
@@ -66,6 +66,25 @@
|
||||
.btn-danger{background:#E03E3E;color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;}
|
||||
.btn-danger:hover{background:#C93535;}
|
||||
|
||||
/* 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:#1A1A1A;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 #2A2A2A;color:var(--text);}
|
||||
.admin-table tr:hover td{background:#1E1E1E;}
|
||||
.stat-card{background:#1A1A1A;border:1px solid #333;border-radius:8px;padding:14px;text-align:center;}
|
||||
.stat-value{font-size:22px;font-weight:700;color:var(--accent,#2383E2);}
|
||||
.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;}
|
||||
.btn-sm{background:transparent;border:1px solid #444;color:var(--text-dim);padding:2px 6px;border-radius:4px;cursor:pointer;font-size:12px;}
|
||||
.btn-sm:hover{background:#333;color:var(--text);}
|
||||
.btn-sm-danger{color:#E03E3E;border-color:#542;}
|
||||
|
||||
/* 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);}
|
||||
@@ -87,8 +106,14 @@
|
||||
<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>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin' }" @click="activeSection='admin'">🔧 Administration</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 -->
|
||||
@@ -277,26 +302,151 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin -->
|
||||
<div x-show="activeSection==='admin'">
|
||||
<h2>Administration</h2>
|
||||
<p class="section-desc">Workspace administration and security.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Security</h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Session timeout</div>
|
||||
<div class="setting-desc">Automatically log out after inactivity</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">30 days</span></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="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Two-factor authentication</div>
|
||||
<div class="setting-desc">Add an extra layer of security</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></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>
|
||||
|
||||
@@ -321,6 +471,15 @@ document.addEventListener('alpine:init', function() {
|
||||
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' }},
|
||||
// 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();
|
||||
@@ -391,6 +550,75 @@ document.addEventListener('alpine:init', function() {
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
// ── Admin ──
|
||||
async loadAdminUsers() {
|
||||
try {
|
||||
var r = await fetch('/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 fetch('/api/admin/stats');
|
||||
this.adminStats = await r.json();
|
||||
} catch(e) { this.adminStats = {}; }
|
||||
},
|
||||
|
||||
async loadAdminAudit() {
|
||||
try {
|
||||
var r = await fetch('/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 fetch('/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();
|
||||
alert(d.error || 'Failed to create user');
|
||||
}
|
||||
} catch(e) { alert('Network 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 fetch('/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 fetch('/api/admin/users/' + id, { method: 'DELETE' });
|
||||
await this.loadAdminUsers();
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async uploadAvatar(ev) {
|
||||
var file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
Reference in New Issue
Block a user