From 23549f1e45f02ade6eadf4310806df6f5c90f268 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sun, 25 Jan 2026 17:04:24 +1300 Subject: [PATCH] Add user stats and activity tracking --- backend/app/auth.py | 24 ++++- backend/app/db.py | 184 ++++++++++++++++++++++++++++++++++ backend/app/routers/auth.py | 34 +++++++ frontend/app/globals.css | 98 +++++++++++++++++- frontend/app/profile/page.tsx | 137 ++++++++++++++++++++++++- frontend/app/users/page.tsx | 8 +- 6 files changed, 472 insertions(+), 13 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index c54c1f3..51942d3 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,15 +1,28 @@ from typing import Dict, Any -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer -from .db import get_user_by_username +from .db import get_user_by_username, upsert_user_activity from .security import safe_decode_token, TokenError oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") +def _extract_client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + parts = [part.strip() for part in forwarded.split(",") if part.strip()] + if parts: + return parts[0] + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() + if request.client and request.client.host: + return request.client.host + return "unknown" -def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: + +def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]: try: payload = safe_decode_token(token) except TokenError as exc: @@ -25,6 +38,11 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: if user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + if request is not None: + ip = _extract_client_ip(request) + user_agent = request.headers.get("user-agent", "unknown") + upsert_user_activity(user["username"], ip, user_agent) + return { "username": user["username"], "role": user["role"], diff --git a/backend/app/db.py b/backend/app/db.py index baef6b8..d883d28 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -103,6 +103,32 @@ def init_db() -> None: ON requests_cache (requested_by_norm) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + ip TEXT NOT NULL, + user_agent TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + hit_count INTEGER NOT NULL DEFAULT 1, + UNIQUE(username, ip, user_agent) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_user_activity_username + ON user_activity (username) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen + ON user_activity (last_seen_at) + """ + ) try: conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT") except sqlite3.OperationalError: @@ -377,6 +403,164 @@ def _backfill_auth_providers() -> None: ) +def upsert_user_activity(username: str, ip: str, user_agent: str) -> None: + if not username: + return + ip_value = ip.strip() if isinstance(ip, str) and ip.strip() else "unknown" + agent_value = ( + user_agent.strip() if isinstance(user_agent, str) and user_agent.strip() else "unknown" + ) + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + conn.execute( + """ + INSERT INTO user_activity (username, ip, user_agent, first_seen_at, last_seen_at, hit_count) + VALUES (?, ?, ?, ?, ?, 1) + ON CONFLICT(username, ip, user_agent) + DO UPDATE SET last_seen_at = excluded.last_seen_at, hit_count = hit_count + 1 + """, + (username, ip_value, agent_value, timestamp, timestamp), + ) + + +def get_user_activity(username: str, limit: int = 5) -> list[Dict[str, Any]]: + limit = max(1, min(limit, 20)) + with _connect() as conn: + rows = conn.execute( + """ + SELECT ip, user_agent, first_seen_at, last_seen_at, hit_count + FROM user_activity + WHERE username = ? + ORDER BY last_seen_at DESC + LIMIT ? + """, + (username, limit), + ).fetchall() + results: list[Dict[str, Any]] = [] + for row in rows: + results.append( + { + "ip": row[0], + "user_agent": row[1], + "first_seen_at": row[2], + "last_seen_at": row[3], + "hit_count": row[4], + } + ) + return results + + +def get_user_activity_summary(username: str) -> Dict[str, Any]: + with _connect() as conn: + last_row = conn.execute( + """ + SELECT ip, user_agent, last_seen_at + FROM user_activity + WHERE username = ? + ORDER BY last_seen_at DESC + LIMIT 1 + """, + (username,), + ).fetchone() + count_row = conn.execute( + """ + SELECT COUNT(*) + FROM user_activity + WHERE username = ? + """, + (username,), + ).fetchone() + return { + "last_ip": last_row[0] if last_row else None, + "last_user_agent": last_row[1] if last_row else None, + "last_seen_at": last_row[2] if last_row else None, + "device_count": int(count_row[0] or 0) if count_row else 0, + } + + +def get_user_request_stats(username_norm: str) -> Dict[str, Any]: + if not username_norm: + return { + "total": 0, + "ready": 0, + "pending": 0, + "approved": 0, + "working": 0, + "partial": 0, + "declined": 0, + "in_progress": 0, + "last_request_at": None, + } + with _connect() as conn: + total_row = conn.execute( + """ + SELECT COUNT(*) + FROM requests_cache + WHERE requested_by_norm = ? + """, + (username_norm,), + ).fetchone() + status_rows = conn.execute( + """ + SELECT status, COUNT(*) + FROM requests_cache + WHERE requested_by_norm = ? + GROUP BY status + """, + (username_norm,), + ).fetchall() + last_row = conn.execute( + """ + SELECT MAX(created_at) + FROM requests_cache + WHERE requested_by_norm = ? + """, + (username_norm,), + ).fetchone() + counts = {int(row[0]): int(row[1]) for row in status_rows if row[0] is not None} + pending = counts.get(1, 0) + approved = counts.get(2, 0) + declined = counts.get(3, 0) + ready = counts.get(4, 0) + working = counts.get(5, 0) + partial = counts.get(6, 0) + in_progress = approved + working + partial + return { + "total": int(total_row[0] or 0) if total_row else 0, + "ready": ready, + "pending": pending, + "approved": approved, + "working": working, + "partial": partial, + "declined": declined, + "in_progress": in_progress, + "last_request_at": last_row[0] if last_row else None, + } + + +def get_global_request_leader() -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + """ + SELECT requested_by_norm, MAX(requested_by) as display_name, COUNT(*) as total + FROM requests_cache + WHERE requested_by_norm IS NOT NULL AND requested_by_norm != '' + GROUP BY requested_by_norm + ORDER BY total DESC + LIMIT 1 + """ + ).fetchone() + if not row: + return None + return {"username": row[1] or row[0], "total": int(row[2] or 0)} + + +def get_global_request_total() -> int: + with _connect() as conn: + row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone() + return int(row[0] or 0) + + def upsert_request_cache( request_id: int, media_id: Optional[int], diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 97f0d67..ad4f9bb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -7,6 +7,11 @@ from ..db import ( set_last_login, get_user_by_username, set_user_password, + get_user_activity, + get_user_activity_summary, + get_user_request_stats, + get_global_request_leader, + get_global_request_total, ) from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient @@ -17,6 +22,10 @@ from ..auth import get_current_user router = APIRouter(prefix="/auth", tags=["auth"]) +def _normalize_username(value: str) -> str: + return value.strip().lower() + + @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) @@ -92,6 +101,31 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict: return current_user +@router.get("/profile") +async def profile(current_user: dict = Depends(get_current_user)) -> dict: + username = current_user.get("username") or "" + username_norm = _normalize_username(username) if username else "" + stats = get_user_request_stats(username_norm) + global_total = get_global_request_total() + leader = get_global_request_leader() + share = (stats.get("total", 0) / global_total) if global_total else 0 + activity_summary = get_user_activity_summary(username) if username else {} + activity_recent = get_user_activity(username, limit=5) if username else [] + return { + "user": current_user, + "stats": { + **stats, + "share": share, + "global_total": global_total, + "most_active_user": leader, + }, + "activity": { + **activity_summary, + "recent": activity_recent, + }, + } + + @router.post("/password") async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: if current_user.get("auth_provider") != "local": diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 9826b62..7795db7 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -559,6 +559,73 @@ button span { margin-top: 4px; } +.profile-grid { + display: grid; + gap: 20px; +} + +.profile-section { + display: grid; + gap: 12px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.stat-card { + padding: 14px; + border-radius: 16px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.05); + display: grid; + gap: 6px; +} + +.stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); +} + +.stat-value { + font-size: 20px; + font-weight: 700; +} + +.stat-value--small { + font-size: 14px; + font-weight: 600; +} + +.connection-list { + display: grid; + gap: 10px; +} + +.connection-item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); +} + +.connection-label { + font-weight: 600; +} + +.connection-count { + font-size: 12px; + color: var(--ink-muted); + white-space: nowrap; +} + .state { display: grid; gap: 6px; @@ -685,12 +752,28 @@ button span { } .user-card { - display: flex; - justify-content: space-between; - align-items: center; + display: grid; + grid-template-columns: 1fr auto; + align-items: start; gap: 16px; } +.user-card strong { + display: block; + font-size: 16px; + margin-bottom: 6px; +} + +.user-meta { + display: grid; + gap: 6px; + font-size: 13px; +} + +.user-meta .meta { + display: block; +} + .user-actions { display: grid; gap: 8px; @@ -1551,6 +1634,15 @@ button span { .cache-row { grid-template-columns: 1fr; } + + .user-card { + grid-template-columns: 1fr; + } + + .connection-item { + flex-direction: column; + align-items: flex-start; + } } @media (max-width: 480px) { diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index f1beac7..5898229 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -10,9 +10,65 @@ type ProfileInfo = { auth_provider: string } +type ProfileStats = { + total: number + ready: number + pending: number + in_progress: number + declined: number + working: number + partial: number + approved: number + last_request_at?: string | null + share: number + global_total: number + most_active_user?: { username: string; total: number } | null +} + +type ActivityEntry = { + ip: string + user_agent: string + first_seen_at: string + last_seen_at: string + hit_count: number +} + +type ProfileActivity = { + last_ip?: string | null + last_user_agent?: string | null + last_seen_at?: string | null + device_count: number + recent: ActivityEntry[] +} + +type ProfileResponse = { + user: ProfileInfo + stats: ProfileStats + activity: ProfileActivity +} + +const formatDate = (value?: string | null) => { + if (!value) return 'Never' + const date = new Date(value) + if (Number.isNaN(date.valueOf())) return value + return date.toLocaleString() +} + +const parseBrowser = (agent?: string | null) => { + if (!agent) return 'Unknown' + const value = agent.toLowerCase() + if (value.includes('edg/')) return 'Edge' + if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome' + if (value.includes('firefox/')) return 'Firefox' + if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari' + return 'Unknown' +} + export default function ProfilePage() { const router = useRouter() const [profile, setProfile] = useState(null) + const [stats, setStats] = useState(null) + const [activity, setActivity] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [status, setStatus] = useState(null) @@ -26,18 +82,21 @@ export default function ProfilePage() { const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/me`) + const response = await authFetch(`${baseUrl}/auth/profile`) if (!response.ok) { clearToken() router.push('/login') return } const data = await response.json() + const user = data?.user ?? {} setProfile({ - username: data?.username ?? 'Unknown', - role: data?.role ?? 'user', - auth_provider: data?.auth_provider ?? 'local', + username: user?.username ?? 'Unknown', + role: user?.role ?? 'user', + auth_provider: user?.auth_provider ?? 'local', }) + setStats(data?.stats ?? null) + setActivity(data?.activity ?? null) } catch (err) { console.error(err) setStatus('Could not load your profile.') @@ -91,6 +150,76 @@ export default function ProfilePage() { {profile.auth_provider}. )} +
+
+

Account stats

+
+
+
Requests submitted
+
{stats?.total ?? 0}
+
+
+
Ready to watch
+
{stats?.ready ?? 0}
+
+
+
In progress
+
{stats?.in_progress ?? 0}
+
+
+
Pending approval
+
{stats?.pending ?? 0}
+
+
+
Declined
+
{stats?.declined ?? 0}
+
+
+
Last request
+
+ {formatDate(stats?.last_request_at)} +
+
+
+
Share of all requests
+
+ {stats?.global_total + ? `${Math.round((stats.share || 0) * 1000) / 10}%` + : '0%'} +
+
+
+
Most active user
+
+ {stats?.most_active_user + ? `${stats.most_active_user.username} (${stats.most_active_user.total})` + : 'N/A'} +
+
+
+
+
+

Connection history

+
+ Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}. +
+
+ {(activity?.recent ?? []).map((entry, index) => ( +
+
+
{parseBrowser(entry.user_agent)}
+
IP: {entry.ip}
+
Last seen: {formatDate(entry.last_seen_at)}
+
+
{entry.hit_count} visits
+
+ ))} + {activity && activity.recent.length === 0 ? ( +
No connection history yet.
+ ) : null} +
+
+
{profile?.auth_provider !== 'local' ? (
Password changes are only available for local Magent accounts. diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index 0171d4a..12bfcbe 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -136,9 +136,11 @@ export default function UsersPage() {
{user.username} - Role: {user.role} - Login type: {user.authProvider || 'local'} - Last login: {formatLastLogin(user.lastLoginAt)} +
+ Role: {user.role} + Login type: {user.authProvider || 'local'} + Last login: {formatLastLogin(user.lastLoginAt)} +