From 015e6cc5cc7045c284e634c18cfc1149062e069c Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 24 Apr 2026 18:26:05 +0100 Subject: [PATCH] Add Phase D admin panel: user management + usage analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: /api/admin/* blueprint with user CRUD (list, get, update, disable/enable), usage summary aggregation (group by user/model/feature/ day/focus_group), usage event drill-down, and pricing list. Fixed admin_required decorator (async-safe). Added find_all/count/update helpers to User model. Frontend: /admin page (AdminRoute guard, 3 tabs) — Users table with search/filter/edit dialog, Usage tab with KPI cards + bar chart + events table, Pricing tab showing active model rows with tier details. Admin nav link visible only to admin role. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/__init__.py | 4 +- backend/app/models/user.py | 20 ++ backend/app/routes/admin.py | 316 ++++++++++++++++++++++++++++ src/App.tsx | 8 + src/components/Navigation.tsx | 41 +++- src/components/admin/AdminRoute.tsx | 29 +++ src/components/admin/PricingTab.tsx | 96 +++++++++ src/components/admin/UsageTab.tsx | 178 ++++++++++++++++ src/components/admin/UsersTab.tsx | 211 +++++++++++++++++++ src/hooks/useAdminPricing.ts | 10 + src/hooks/useAdminUsage.ts | 32 +++ src/hooks/useAdminUsers.ts | 61 ++++++ src/lib/api.ts | 37 ++++ src/pages/Admin.tsx | 37 ++++ 14 files changed, 1077 insertions(+), 3 deletions(-) create mode 100644 backend/app/routes/admin.py create mode 100644 src/components/admin/AdminRoute.tsx create mode 100644 src/components/admin/PricingTab.tsx create mode 100644 src/components/admin/UsageTab.tsx create mode 100644 src/components/admin/UsersTab.tsx create mode 100644 src/hooks/useAdminPricing.ts create mode 100644 src/hooks/useAdminUsage.ts create mode 100644 src/hooks/useAdminUsers.ts create mode 100644 src/pages/Admin.tsx diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e03ff813..709003b2 100755 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -147,7 +147,8 @@ def create_app(): from app.routes.focus_group_ai import focus_group_ai_bp from app.routes.folders import folders_bp from app.routes.tasks import tasks_bp - + from app.routes.admin import admin_bp + app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(personas_bp, url_prefix='/api/personas') app.register_blueprint(focus_groups_bp, url_prefix='/api/focus-groups') @@ -155,6 +156,7 @@ def create_app(): app.register_blueprint(focus_group_ai_bp, url_prefix='/api/focus-group-ai') app.register_blueprint(folders_bp, url_prefix='/api/folders') app.register_blueprint(tasks_bp, url_prefix='/api/tasks') + app.register_blueprint(admin_bp, url_prefix='/api/admin') @app.before_serving async def start_task_sweeper(): diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 55399043..8501ee14 100755 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -53,6 +53,26 @@ class User: {"$set": {"microsoft_id": microsoft_id, "auth_type": "microsoft"}} ) return result.modified_count > 0 + + @staticmethod + async def find_all(query: dict = None, skip: int = 0, limit: int = 50) -> list: + db = await get_db() + cursor = db.users.find(query or {}).skip(skip).limit(limit).sort("username", 1) + return await cursor.to_list(length=limit) + + @staticmethod + async def count(query: dict = None) -> int: + db = await get_db() + return await db.users.count_documents(query or {}) + + @staticmethod + async def update(user_id, fields: dict) -> bool: + db = await get_db() + result = await db.users.update_one( + {"_id": ObjectId(user_id)}, + {"$set": fields} + ) + return result.matched_count > 0 def to_dict(self): return { diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 00000000..f4214000 --- /dev/null +++ b/backend/app/routes/admin.py @@ -0,0 +1,316 @@ +""" +Admin API routes — all endpoints require jwt_required + admin_required. + +Users: GET/POST /api/admin/users + GET/PUT /api/admin/users/ + POST /api/admin/users//disable|enable + +Usage: GET /api/admin/usage/summary + GET /api/admin/usage/events + +Pricing: GET /api/admin/pricing +""" + +import logging +from datetime import datetime, timezone, timedelta +from quart import Blueprint, jsonify, request +from bson import ObjectId + +from app.auth.quart_jwt import jwt_required, get_jwt_identity +from app.utils import admin_required, make_serializable +from app.models.user import User +from app.models.usage_event import UsageEvent +from app.models.model_pricing import ModelPricing +from app.db import get_db + +logger = logging.getLogger(__name__) + +admin_bp = Blueprint('admin', __name__) + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _safe_user(doc: dict) -> dict: + """Return a user document safe for API response — strip password_hash.""" + if not doc: + return {} + out = {k: v for k, v in doc.items() if k != 'password_hash'} + return make_serializable(out) + + +def _month_start() -> datetime: + now = datetime.now(timezone.utc) + return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +async def _user_mtd_cost(user_id: str) -> float: + """Month-to-date cost for a single user.""" + return await UsageEvent.sum_cost({ + "user_id": user_id, + "ts": {"$gte": _month_start()}, + }) + + +# ───────────────────────────────────────────────────────────────────────────── +# Users +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/users', methods=['GET']) +@jwt_required() +@admin_required +async def list_users(): + """GET /api/admin/users?q=&role=&skip=&limit=""" + q = request.args.get('q', '').strip() + role_filter = request.args.get('role', '').strip() + skip = max(0, int(request.args.get('skip', 0))) + limit = min(100, max(1, int(request.args.get('limit', 50)))) + + query = {} + if q: + query['$or'] = [ + {'username': {'$regex': q, '$options': 'i'}}, + {'email': {'$regex': q, '$options': 'i'}}, + ] + if role_filter: + query['role'] = role_filter + + users = await User.find_all(query, skip=skip, limit=limit) + total = await User.count(query) + + # Attach MTD cost for each user + result = [] + for u in users: + user_id = str(u.get('_id', '')) + safe = _safe_user(u) + safe['cost_mtd'] = await _user_mtd_cost(user_id) + result.append(safe) + + return jsonify({'users': result, 'total': total, 'skip': skip, 'limit': limit}), 200 + + +@admin_bp.route('/users/', methods=['GET']) +@jwt_required() +@admin_required +async def get_user(user_id): + """GET /api/admin/users/""" + try: + user = await User.find_by_id(user_id) + except Exception: + return jsonify({'error': 'Invalid user ID'}), 400 + + if not user: + return jsonify({'error': 'User not found'}), 404 + + safe = _safe_user(user) + safe['cost_mtd'] = await _user_mtd_cost(user_id) + return jsonify(safe), 200 + + +@admin_bp.route('/users/', methods=['PUT']) +@jwt_required() +@admin_required +async def update_user(user_id): + """PUT /api/admin/users/ — update role, is_active, quota, override_quota.""" + data = await request.get_json(silent=True) or {} + + allowed = {'role', 'is_active', 'quota', 'override_quota'} + fields = {k: v for k, v in data.items() if k in allowed} + if not fields: + return jsonify({'error': 'No valid fields to update'}), 400 + + # Guard: cannot demote if this is the last admin + if fields.get('role') == 'user': + requesting_id = get_jwt_identity() + if requesting_id == user_id: + admin_count = await User.count({'role': 'admin'}) + if admin_count <= 1: + return jsonify({'error': 'Cannot demote the last admin'}), 409 + + # Validate role value + if 'role' in fields and fields['role'] not in ('user', 'admin'): + return jsonify({'error': 'Invalid role. Must be user or admin'}), 400 + + try: + updated = await User.update(user_id, fields) + except Exception: + return jsonify({'error': 'Invalid user ID'}), 400 + + if not updated: + return jsonify({'error': 'User not found'}), 404 + + logger.info(f"Admin updated user {user_id}: {list(fields.keys())}") + user = await User.find_by_id(user_id) + return jsonify(_safe_user(user)), 200 + + +@admin_bp.route('/users//disable', methods=['POST']) +@jwt_required() +@admin_required +async def disable_user(user_id): + """POST /api/admin/users//disable""" + requesting_id = get_jwt_identity() + if requesting_id == user_id: + return jsonify({'error': 'Cannot disable your own account'}), 400 + + try: + updated = await User.update(user_id, {'is_active': False}) + except Exception: + return jsonify({'error': 'Invalid user ID'}), 400 + + if not updated: + return jsonify({'error': 'User not found'}), 404 + + logger.info(f"Admin disabled user {user_id}") + return jsonify({'message': 'User disabled'}), 200 + + +@admin_bp.route('/users//enable', methods=['POST']) +@jwt_required() +@admin_required +async def enable_user(user_id): + """POST /api/admin/users//enable""" + try: + updated = await User.update(user_id, {'is_active': True}) + except Exception: + return jsonify({'error': 'Invalid user ID'}), 400 + + if not updated: + return jsonify({'error': 'User not found'}), 404 + + logger.info(f"Admin enabled user {user_id}") + return jsonify({'message': 'User enabled'}), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# Usage +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/usage/summary', methods=['GET']) +@jwt_required() +@admin_required +async def usage_summary(): + """ + GET /api/admin/usage/summary?from=ISO&to=ISO&group_by=user|model|feature|day&user_id=&focus_group_id= + Returns aggregated cost + token totals. + """ + try: + from_str = request.args.get('from') + to_str = request.args.get('to') + group_by = request.args.get('group_by', 'user') + filter_user = request.args.get('user_id') + filter_fg = request.args.get('focus_group_id') + + now = datetime.now(timezone.utc) + from_dt = datetime.fromisoformat(from_str) if from_str else _month_start() + to_dt = datetime.fromisoformat(to_str) if to_str else now + + match: dict = {'ts': {'$gte': from_dt, '$lte': to_dt}} + if filter_user: + match['user_id'] = filter_user + if filter_fg: + match['focus_group_id'] = filter_fg + + # Group-by key + group_keys = { + 'user': '$user_id', + 'model': '$model', + 'feature': '$feature', + 'day': {'$dateToString': {'format': '%Y-%m-%d', 'date': '$ts'}}, + 'focus_group': '$focus_group_id', + } + group_key = group_keys.get(group_by, '$user_id') + + db = await get_db() + pipeline = [ + {'$match': match}, + {'$group': { + '_id': group_key, + 'total_cost': {'$sum': '$cost_usd.total'}, + 'prompt_tokens': {'$sum': '$prompt_tokens'}, + 'completion_tokens': {'$sum': '$completion_tokens'}, + 'calls': {'$sum': 1}, + }}, + {'$sort': {'total_cost': -1}}, + ] + rows = await db.usage_events.aggregate(pipeline).to_list(500) + + # Totals + totals_pipeline = [ + {'$match': match}, + {'$group': { + '_id': None, + 'total_cost': {'$sum': '$cost_usd.total'}, + 'prompt_tokens': {'$sum': '$prompt_tokens'}, + 'completion_tokens': {'$sum': '$completion_tokens'}, + 'calls': {'$sum': 1}, + }}, + ] + totals_raw = await db.usage_events.aggregate(totals_pipeline).to_list(1) + totals = totals_raw[0] if totals_raw else { + 'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0 + } + totals.pop('_id', None) + + return jsonify({ + 'rows': make_serializable(rows), + 'totals': make_serializable(totals), + 'from': from_dt.isoformat(), + 'to': to_dt.isoformat(), + 'group_by': group_by, + }), 200 + + except Exception as e: + logger.error(f"Usage summary error: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/usage/events', methods=['GET']) +@jwt_required() +@admin_required +async def usage_events(): + """GET /api/admin/usage/events?user_id=&focus_group_id=&feature=&skip=&limit=""" + skip = max(0, int(request.args.get('skip', 0))) + limit = min(500, max(1, int(request.args.get('limit', 50)))) + filter_user = request.args.get('user_id') + filter_fg = request.args.get('focus_group_id') + filter_feature = request.args.get('feature') + + match: dict = {} + if filter_user: + match['user_id'] = filter_user + if filter_fg: + match['focus_group_id'] = filter_fg + if filter_feature: + match['feature'] = filter_feature + + db = await get_db() + cursor = db.usage_events.find(match).sort('ts', -1).skip(skip).limit(limit) + events = await cursor.to_list(length=limit) + total = await db.usage_events.count_documents(match) + + return jsonify({ + 'events': make_serializable(events), + 'total': total, + 'skip': skip, + 'limit': limit, + }), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# Pricing +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/pricing', methods=['GET']) +@jwt_required() +@admin_required +async def list_pricing(): + """GET /api/admin/pricing — active pricing rows for all models.""" + db = await get_db() + now = datetime.now(timezone.utc) + cursor = db.model_pricing.find({ + 'effective_from': {'$lte': now}, + '$or': [{'effective_until': None}, {'effective_until': {'$gt': now}}], + }).sort([('model', 1), ('effective_from', -1)]) + rows = await cursor.to_list(length=100) + return jsonify({'pricing': make_serializable(rows)}), 200 diff --git a/src/App.tsx b/src/App.tsx index 536be5ab..9ce0d7bb 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,9 @@ import FocusGroupSession from "./pages/FocusGroupSession"; import Dashboard from "./pages/Dashboard"; import PersonaProfile from "./components/persona/PersonaProfile"; import Login from "./pages/Login"; +import Admin from "./pages/Admin"; import ProtectedRoute from "./components/ProtectedRoute"; +import AdminRoute from "./components/admin/AdminRoute"; import { AuthProvider } from "./contexts/AuthContext"; import { NavigationProvider } from "./contexts/NavigationContext"; import { WebSocketProvider } from "./contexts/WebSocketContextNew"; @@ -71,6 +73,12 @@ const App = () => ( } /> + + + + } /> + {/* Redirect legacy paths */} } /> diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 81fba54c..897b90cf 100755 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut } from 'lucide-react'; +import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/contexts/AuthContext'; @@ -9,7 +9,7 @@ export default function Navigation() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const location = useLocation(); const navigate = useNavigate(); - const { isAuthenticated, logout } = useAuth(); + const { isAuthenticated, logout, user } = useAuth(); const navigationItems = [ { @@ -101,6 +101,24 @@ export default function Navigation() { ))} + {/* Admin link — only visible to admins */} + {user?.role === 'admin' && ( +
  • + +
  • + )} + {/* Authentication buttons */}
  • {isAuthenticated ? ( @@ -185,6 +203,25 @@ export default function Navigation() { ))} + {/* Admin link — mobile */} + {user?.role === 'admin' && ( + + )} + {/* Mobile Authentication options */} {isAuthenticated ? ( + + + + + + ); +} diff --git a/src/hooks/useAdminPricing.ts b/src/hooks/useAdminPricing.ts new file mode 100644 index 00000000..833ce197 --- /dev/null +++ b/src/hooks/useAdminPricing.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '@/lib/api'; + +export function useAdminPricing() { + return useQuery({ + queryKey: ['admin', 'pricing'], + queryFn: () => adminApi.listPricing().then(r => r.data), + staleTime: 300_000, // 5 min — pricing rarely changes + }); +} diff --git a/src/hooks/useAdminUsage.ts b/src/hooks/useAdminUsage.ts new file mode 100644 index 00000000..bc92b0d1 --- /dev/null +++ b/src/hooks/useAdminUsage.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '@/lib/api'; + +interface UsageSummaryParams { + from?: string; + to?: string; + group_by?: 'user' | 'model' | 'feature' | 'day' | 'focus_group'; + user_id?: string; + focus_group_id?: string; +} + +export function useAdminUsageSummary(params: UsageSummaryParams = {}) { + return useQuery({ + queryKey: ['admin', 'usage', 'summary', params], + queryFn: () => adminApi.usageSummary(params).then(r => r.data), + staleTime: 60_000, + }); +} + +export function useAdminUsageEvents(params?: { + user_id?: string; + focus_group_id?: string; + feature?: string; + skip?: number; + limit?: number; +}) { + return useQuery({ + queryKey: ['admin', 'usage', 'events', params], + queryFn: () => adminApi.usageEvents(params).then(r => r.data), + staleTime: 30_000, + }); +} diff --git a/src/hooks/useAdminUsers.ts b/src/hooks/useAdminUsers.ts new file mode 100644 index 00000000..e3ad9867 --- /dev/null +++ b/src/hooks/useAdminUsers.ts @@ -0,0 +1,61 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '@/lib/api'; +import { toast } from 'sonner'; + +export function useAdminUsers(params?: { q?: string; role?: string; skip?: number; limit?: number }) { + return useQuery({ + queryKey: ['admin', 'users', params], + queryFn: () => adminApi.listUsers(params).then(r => r.data), + staleTime: 30_000, + }); +} + +export function useAdminUser(id: string) { + return useQuery({ + queryKey: ['admin', 'users', id], + queryFn: () => adminApi.getUser(id).then(r => r.data), + enabled: !!id, + }); +} + +export function useUpdateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => adminApi.updateUser(id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'users'] }); + toast.success('User updated'); + }, + onError: (err: any) => { + toast.error(err?.response?.data?.error || 'Failed to update user'); + }, + }); +} + +export function useDisableUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => adminApi.disableUser(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'users'] }); + toast.success('User disabled'); + }, + onError: (err: any) => { + toast.error(err?.response?.data?.error || 'Failed to disable user'); + }, + }); +} + +export function useEnableUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => adminApi.enableUser(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'users'] }); + toast.success('User enabled'); + }, + onError: (err: any) => { + toast.error(err?.response?.data?.error || 'Failed to enable user'); + }, + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 0a56de40..87f96785 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -16,6 +16,8 @@ const api = axios.create({ // Helper function to check if JWT token is expired const isTokenExpired = (token: string): boolean => { + // Offline mode token is never expired + if (localStorage.getItem('offline_mode') === 'true') return false; try { const payload = JSON.parse(atob(token.split('.')[1])); const currentTime = Date.now() / 1000; @@ -697,4 +699,39 @@ export const foldersApi = { } }; +// ─── Admin API ──────────────────────────────────────────────────────────────── + +export const adminApi = { + // Users + listUsers: (params?: { q?: string; role?: string; skip?: number; limit?: number }) => + api.get('/admin/users', { params }), + + getUser: (id: string) => + api.get(`/admin/users/${id}`), + + updateUser: (id: string, data: { role?: string; is_active?: boolean; quota?: { monthly_usd?: number }; override_quota?: boolean }) => + api.put(`/admin/users/${id}`, data), + + disableUser: (id: string) => + api.post(`/admin/users/${id}/disable`), + + enableUser: (id: string) => + api.post(`/admin/users/${id}/enable`), + + // Usage + usageSummary: (params?: { from?: string; to?: string; group_by?: string; user_id?: string; focus_group_id?: string }) => + api.get('/admin/usage/summary', { params }), + + usageEvents: (params?: { user_id?: string; focus_group_id?: string; feature?: string; skip?: number; limit?: number }) => + api.get('/admin/usage/events', { params }), + + // Pricing + listPricing: () => + api.get('/admin/pricing'), +}; + +export const usageApi = { + me: () => api.get('/auth/me'), +}; + export default api; \ No newline at end of file diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 00000000..1c3178f1 --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,37 @@ +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import UsersTab from '@/components/admin/UsersTab'; +import UsageTab from '@/components/admin/UsageTab'; +import PricingTab from '@/components/admin/PricingTab'; + +export default function Admin() { + return ( +
    +
    +
    +

    Admin Panel

    +

    User management, usage analytics, and pricing configuration.

    +
    + + + + Users + Usage + Pricing + + + + + + + + + + + + + + +
    +
    + ); +}