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 @@
Workspace administration and security.
-Manage user accounts, roles, and permissions.
+ + +| User | +Role | +Status | +Workspaces | +Files | +Folders | +Storage | +Last Login | +Actions | +
|---|---|---|---|---|---|---|---|---|
| + + + | ++ + | ++ + + | ++ | + | + | + | + + | +
+
+
+
+
+ |
+
Login history and security events.
+| Date | +User | +IP Address | +User Agent | +
|---|---|---|---|
| + | + + + | + |
+ + |
| No login events yet | +|||