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:
@@ -37,6 +37,14 @@ def init_db():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gitea_user_id INTEGER NOT NULL UNIQUE,
|
||||
@@ -356,6 +364,21 @@ def init_db():
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.5: Login history
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
@@ -58,6 +58,7 @@ app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(public_api.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
|
||||
|
||||
# ── Dependency ──
|
||||
async def admin_required(request: Request):
|
||||
from app.auth.session import get_current_user
|
||||
user = await get_current_user(request)
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ── Users ──
|
||||
@router.get("/users")
|
||||
async def list_users(_admin=Depends(admin_required)):
|
||||
"""List all users with workspace/file/folder counts and storage usage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.login, u.full_name, u.email, u.is_admin, u.is_active,
|
||||
u.last_login, u.created_at,
|
||||
(SELECT COUNT(*) FROM workspaces WHERE owner_id=u.id) AS ws_count,
|
||||
(SELECT COUNT(*) FROM pages WHERE owner_id=u.id AND is_folder=0) AS file_count,
|
||||
(SELECT COUNT(*) FROM pages WHERE owner_id=u.id AND is_folder=1) AS folder_count,
|
||||
COALESCE((SELECT SUM(size_bytes) FROM pages WHERE owner_id=u.id), 0) AS total_bytes
|
||||
FROM users u
|
||||
ORDER BY u.id
|
||||
""").fetchall()
|
||||
users = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["total_mb"] = round(d["total_bytes"] / (1024 * 1024), 2)
|
||||
users.append(d)
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
"""Create a new user (admin only)."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = body.get("login", "").strip()
|
||||
name = body.get("name", login)
|
||||
email = body.get("email", login)
|
||||
password = body.get("password", "").strip()
|
||||
is_admin = int(body.get("is_admin", 0))
|
||||
if not login or not password:
|
||||
return JSONResponse({"error": "Login and password required"}, status_code=400)
|
||||
if len(password) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()
|
||||
if existing:
|
||||
return JSONResponse({"error": "User already exists"}, status_code=409)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, name, email, hash_password(password), is_admin),
|
||||
)
|
||||
conn.commit()
|
||||
uid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
return {"status": "ok", "user": {"id": uid, "login": login}}
|
||||
|
||||
|
||||
@router.put("/users/{user_id:int}")
|
||||
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||
if "name" in body:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["name"], user_id))
|
||||
if "email" in body:
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"], user_id))
|
||||
if "password" in body and body["password"].strip():
|
||||
pw = body["password"].strip()
|
||||
if len(pw) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), user_id))
|
||||
if "is_admin" in body:
|
||||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (int(body["is_admin"]), user_id))
|
||||
if "is_active" in body:
|
||||
conn.execute("UPDATE users SET is_active=? WHERE id=?", (int(body["is_active"]), user_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id:int}")
|
||||
async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||
conn.execute("DELETE FROM login_history WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM pages WHERE owner_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
async def user_stats(_admin=Depends(admin_required)):
|
||||
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
total_ws = conn.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0]
|
||||
total_files = conn.execute("SELECT COUNT(*) FROM pages WHERE is_folder=0").fetchone()[0]
|
||||
total_folders = conn.execute("SELECT COUNT(*) FROM pages WHERE is_folder=1").fetchone()[0]
|
||||
total_bytes = conn.execute("SELECT COALESCE(SUM(size_bytes), 0) FROM pages").fetchone()[0]
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"total_workspaces": total_ws,
|
||||
"total_files": total_files,
|
||||
"total_folders": total_folders,
|
||||
"total_mb": round(total_bytes / (1024 * 1024), 2),
|
||||
}
|
||||
|
||||
|
||||
# ── Audit ──
|
||||
@router.get("/audit")
|
||||
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
"""Recent login history."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT lh.id, lh.user_id, u.login, u.full_name,
|
||||
lh.ip_address, lh.user_agent, lh.logged_at
|
||||
FROM login_history lh
|
||||
JOIN users u ON u.id = lh.user_id
|
||||
ORDER BY lh.logged_at DESC
|
||||
LIMIT ?
|
||||
""", (min(limit, 500),)).fetchall()
|
||||
return {"entries": [dict(r) for r in rows]}
|
||||
+26
-2
@@ -99,6 +99,7 @@ async def login(request: Request, provider: str = Query("gitea")):
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
_log_login(user_data["id"], request)
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
@@ -136,13 +137,18 @@ async def register(request: Request):
|
||||
if existing:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Account already exists"}, status_code=409)
|
||||
# First user is admin
|
||||
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
is_admin = 1 if user_count == 0 else 0
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash) VALUES (?, ?, ?, ?)",
|
||||
(email, name, email, hash_password(password)),
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
(email, name, email, hash_password(password), is_admin),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
user_data = dict(user)
|
||||
# Log login
|
||||
_log_login(user_data["id"], request)
|
||||
session = SessionManager.create_session(user_data)
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
||||
@@ -199,6 +205,7 @@ async def local_login(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
session = SessionManager.create_session(ud)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
@@ -265,6 +272,7 @@ async def callback(
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
_log_login(user_data["id"], request)
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
@@ -286,3 +294,19 @@ async def current_user(request: Request):
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
try:
|
||||
from app.db import get_conn
|
||||
ip = request.client.host if request.client else ''
|
||||
ua = request.headers.get('user-agent', '')[:500]
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO login_history (user_id, ip_address, user_agent) VALUES (?, ?, ?)",
|
||||
(user_id, ip, ua),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+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