diff --git a/app/db.py b/app/db.py index e49f719..2946ce4 100644 --- a/app/db.py +++ b/app/db.py @@ -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() diff --git a/app/main.py b/app/main.py index 0707e3e..7a1a1dc 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..e210781 --- /dev/null +++ b/app/routers/admin.py @@ -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]} diff --git a/app/routers/auth.py b/app/routers/auth.py index 805d2a6..2a55776 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 diff --git a/app/templates/settings.html b/app/templates/settings.html index b363396..2a0ef78 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -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 @@
🏷️ Tags
Features
🧩 Integrations
-
Admin
-
🔧 Administration
+ + @@ -277,26 +302,151 @@ - -
-

Administration

-

Workspace administration and security.

-
-

Security

-
-
-
Session timeout
-
Automatically log out after inactivity
-
-
30 days
+ +
+

Users & Roles

+

Manage user accounts, roles, and permissions.

+ + +
+
+
—
+
Total Users
-
-
-
Two-factor authentication
-
Add an extra layer of security
-
-
Coming soon
+
+
—
+
Workspaces
+
+
—
+
Files
+
+
+
— MB
+
Storage
+
+
+ + +
+ +
+ + +
+ + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
UserRoleStatusWorkspacesFilesFoldersStorageLast LoginActions
+
+ + + +
+ + +
+

Audit Log

+

Login history and security events.

+
+ + + + + + + + + + + + + + + +
DateUserIP AddressUser Agent
No login events yet
@@ -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;