Add Phase D admin panel: user management + usage analytics
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 <noreply@anthropic.com>
This commit is contained in:
parent
8c5146022a
commit
015e6cc5cc
14 changed files with 1077 additions and 3 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
316
backend/app/routes/admin.py
Normal file
316
backend/app/routes/admin.py
Normal file
|
|
@ -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/<id>
|
||||
POST /api/admin/users/<id>/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/<user_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def get_user(user_id):
|
||||
"""GET /api/admin/users/<id>"""
|
||||
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/<user_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def update_user(user_id):
|
||||
"""PUT /api/admin/users/<id> — 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/<user_id>/disable', methods=['POST'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def disable_user(user_id):
|
||||
"""POST /api/admin/users/<id>/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/<user_id>/enable', methods=['POST'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def enable_user(user_id):
|
||||
"""POST /api/admin/users/<id>/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
|
||||
|
|
@ -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 = () => (
|
|||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/admin" element={
|
||||
<AdminRoute>
|
||||
<Admin />
|
||||
</AdminRoute>
|
||||
} />
|
||||
|
||||
{/* Redirect legacy paths */}
|
||||
<Route path="/old-path" element={<Navigate to="/" replace />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</li>
|
||||
))}
|
||||
|
||||
{/* Admin link — only visible to admins */}
|
||||
{user?.role === 'admin' && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => handleAuthNavigation('/admin')}
|
||||
className={cn(
|
||||
"flex items-center px-1 py-2 text-sm font-medium hover-transition border-b-2",
|
||||
isActive('/admin')
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300"
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="mr-1 h-4 w-4" />
|
||||
Admin
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* Authentication buttons */}
|
||||
<li>
|
||||
{isAuthenticated ? (
|
||||
|
|
@ -185,6 +203,25 @@ export default function Navigation() {
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* Admin link — mobile */}
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center rounded-md px-3 py-2 text-base font-medium button-transition w-full text-left",
|
||||
isActive('/admin')
|
||||
? "bg-primary text-white"
|
||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||
)}
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
handleAuthNavigation('/admin');
|
||||
}}
|
||||
>
|
||||
<ShieldCheck className="mr-3 h-5 w-5" />
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile Authentication options */}
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
|
|
|
|||
29
src/components/admin/AdminRoute.tsx
Normal file
29
src/components/admin/AdminRoute.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminRoute({ children }: AdminRouteProps) {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||
}
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
96
src/components/admin/PricingTab.tsx
Normal file
96
src/components/admin/PricingTab.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useAdminPricing } from '@/hooks/useAdminPricing';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface PricingTier {
|
||||
threshold_input_tokens: number;
|
||||
input_per_mtok: number;
|
||||
cached_input_per_mtok?: number | null;
|
||||
output_per_mtok: number;
|
||||
image_per_mtok?: number | null;
|
||||
}
|
||||
|
||||
interface PricingRow {
|
||||
_id: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
tiers: PricingTier[];
|
||||
effective_from: string;
|
||||
effective_until?: string | null;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export default function PricingTab() {
|
||||
const { data, isLoading } = useAdminPricing();
|
||||
const rows: PricingRow[] = data?.pricing ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-500">Active pricing rows used for cost calculations. New rows can be added via API.</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Tiers</TableHead>
|
||||
<TableHead>Input / Cached / Output</TableHead>
|
||||
<TableHead>Effective from</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
No pricing rows found. Run <code className="bg-slate-100 px-1 rounded">seed_model_pricing.py</code>.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map(row => (
|
||||
<TableRow key={row._id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{row.model}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs capitalize">{row.provider}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{row.tiers?.length ?? 1}</TableCell>
|
||||
<TableCell>
|
||||
{(row.tiers ?? []).map((tier, i) => (
|
||||
<div key={i} className="text-xs font-mono leading-relaxed">
|
||||
{tier.threshold_input_tokens > 0 && (
|
||||
<span className="text-slate-400 mr-1">>{(tier.threshold_input_tokens / 1000).toFixed(0)}k:</span>
|
||||
)}
|
||||
<span>${tier.input_per_mtok}/M</span>
|
||||
{tier.cached_input_per_mtok != null && (
|
||||
<span className="text-slate-500"> / ${tier.cached_input_per_mtok}/M cached</span>
|
||||
)}
|
||||
<span> / ${tier.output_per_mtok}/M out</span>
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">
|
||||
{row.effective_from ? new Date(row.effective_from).toLocaleDateString() : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.effective_until ? (
|
||||
<Badge variant="secondary" className="text-xs">Expired</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-green-700 border-green-300">Active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/admin/UsageTab.tsx
Normal file
178
src/components/admin/UsageTab.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useState } from 'react';
|
||||
import { useAdminUsageSummary, useAdminUsageEvents } from '@/hooks/useAdminUsage';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, DollarSign, Zap, Activity, BarChart2 } from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
type GroupBy = 'user' | 'model' | 'feature' | 'day' | 'focus_group';
|
||||
|
||||
function kpiCards(totals: any) {
|
||||
return [
|
||||
{ label: 'Total Cost (MTD)', value: `$${(totals?.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
|
||||
{ label: 'LLM Calls', value: (totals?.calls ?? 0).toLocaleString(), icon: Activity },
|
||||
{ label: 'Prompt Tokens', value: ((totals?.prompt_tokens ?? 0) / 1000).toFixed(1) + 'k', icon: Zap },
|
||||
{ label: 'Completion Tokens', value: ((totals?.completion_tokens ?? 0) / 1000).toFixed(1) + 'k', icon: BarChart2 },
|
||||
];
|
||||
}
|
||||
|
||||
export default function UsageTab() {
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('user');
|
||||
|
||||
const { data, isLoading } = useAdminUsageSummary({ group_by: groupBy });
|
||||
const { data: eventsData, isLoading: eventsLoading } = useAdminUsageEvents({ limit: 20 });
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const totals = data?.totals ?? {};
|
||||
const events = eventsData?.events ?? [];
|
||||
|
||||
const chartData = rows.slice(0, 15).map((r: any) => ({
|
||||
name: r._id ?? 'unknown',
|
||||
cost: parseFloat((r.total_cost ?? 0).toFixed(6)),
|
||||
calls: r.calls ?? 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{kpiCards(totals).map(({ label, value, icon: Icon }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-1 pt-3 px-4">
|
||||
<CardTitle className="text-xs font-medium text-slate-500">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="text-xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Group-by selector + bar chart */}
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Cost breakdown</span>
|
||||
<Select value={groupBy} onValueChange={v => setGroupBy(v as GroupBy)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">By user</SelectItem>
|
||||
<SelectItem value="model">By model</SelectItem>
|
||||
<SelectItem value="feature">By feature</SelectItem>
|
||||
<SelectItem value="day">By day</SelectItem>
|
||||
<SelectItem value="focus_group">By focus group</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="text-center text-slate-500 py-8 text-sm">No data for this period</p>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: 260 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888"
|
||||
fontSize={11}
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis stroke="#888" fontSize={11} tickFormatter={v => `$${v}`} />
|
||||
<Tooltip formatter={(v: any) => [`$${Number(v).toFixed(6)}`, 'Cost']} />
|
||||
<Bar dataKey="cost" fill="#3B82F6" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary table */}
|
||||
{!isLoading && rows.length > 0 && (
|
||||
<div className="rounded border overflow-auto max-h-64">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{groupBy === 'day' ? 'Date' : groupBy.replace('_', ' ')}</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Calls</TableHead>
|
||||
<TableHead className="text-right">Prompt tok</TableHead>
|
||||
<TableHead className="text-right">Output tok</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r: any, i: number) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-xs">{r._id ?? '—'}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">${(r.total_cost ?? 0).toFixed(6)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{r.calls}</TableCell>
|
||||
<TableCell className="text-right text-xs">{((r.prompt_tokens ?? 0) / 1000).toFixed(1)}k</TableCell>
|
||||
<TableCell className="text-right text-xs">{((r.completion_tokens ?? 0) / 1000).toFixed(1)}k</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent events */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Recent events</h3>
|
||||
{eventsLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Feature</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Tokens</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-6">No events yet</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{events.map((e: any, i: number) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-xs whitespace-nowrap">
|
||||
{e.ts ? new Date(e.ts).toLocaleString() : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{e.feature ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{e.model ?? '—'}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">${(e.cost_usd?.total ?? 0).toFixed(6)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{(e.total_tokens ?? 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<span className={e.status === 'error' ? 'text-red-500' : 'text-green-600'}>
|
||||
{e.status ?? 'success'}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/components/admin/UsersTab.tsx
Normal file
211
src/components/admin/UsersTab.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { useState } from 'react';
|
||||
import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser } from '@/hooks/useAdminUsers';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, UserCog, ShieldCheck, Ban, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
is_active?: boolean;
|
||||
override_quota?: boolean;
|
||||
quota?: { monthly_usd?: number };
|
||||
cost_mtd?: number;
|
||||
}
|
||||
|
||||
export default function UsersTab() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
const [editRole, setEditRole] = useState('user');
|
||||
const [editQuota, setEditQuota] = useState('');
|
||||
const [editOverride, setEditOverride] = useState(false);
|
||||
|
||||
const { data, isLoading } = useAdminUsers({ q: search, role: roleFilter || undefined });
|
||||
const updateUser = useUpdateUser();
|
||||
const disableUser = useDisableUser();
|
||||
const enableUser = useEnableUser();
|
||||
|
||||
const users: User[] = data?.users || [];
|
||||
|
||||
const openEdit = (u: User) => {
|
||||
setEditUser(u);
|
||||
setEditRole(u.role);
|
||||
setEditQuota(u.quota?.monthly_usd?.toString() ?? '');
|
||||
setEditOverride(u.override_quota ?? false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editUser) return;
|
||||
const payload: any = { role: editRole, override_quota: editOverride };
|
||||
if (editQuota) {
|
||||
payload.quota = { monthly_usd: parseFloat(editQuota) };
|
||||
} else {
|
||||
payload.quota = {};
|
||||
}
|
||||
updateUser.mutate({ id: editUser._id, data: payload }, {
|
||||
onSuccess: () => setEditUser(null),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter || 'all'} onValueChange={v => setRoleFilter(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All roles</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>MTD Cost</TableHead>
|
||||
<TableHead>Monthly Quota</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map(u => (
|
||||
<TableRow key={u._id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{u.username}</div>
|
||||
<div className="text-xs text-slate-500">{u.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{u.role}
|
||||
</Badge>
|
||||
{u.override_quota && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">no quota</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.is_active === false ? 'destructive' : 'outline'}>
|
||||
{u.is_active === false ? 'Disabled' : 'Active'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
${(u.cost_mtd ?? 0).toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-600">
|
||||
{u.quota?.monthly_usd ? `$${u.quota.monthly_usd}/mo` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={() => openEdit(u)} title="Edit">
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
{u.is_active === false ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => enableUser.mutate(u._id)} title="Enable">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={() => disableUser.mutate(u._id)} title="Disable">
|
||||
<Ban className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editUser} onOpenChange={open => !open && setEditUser(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Monthly quota (USD, blank = unlimited)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="e.g. 50"
|
||||
value={editQuota}
|
||||
onChange={e => setEditQuota(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="override"
|
||||
type="checkbox"
|
||||
checked={editOverride}
|
||||
onChange={e => setEditOverride(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
/>
|
||||
<Label htmlFor="override" className="cursor-pointer">
|
||||
Override quota (bypass spending limit)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={updateUser.isPending}>
|
||||
{updateUser.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/hooks/useAdminPricing.ts
Normal file
10
src/hooks/useAdminPricing.ts
Normal file
|
|
@ -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
|
||||
});
|
||||
}
|
||||
32
src/hooks/useAdminUsage.ts
Normal file
32
src/hooks/useAdminUsage.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
61
src/hooks/useAdminUsers.ts
Normal file
61
src/hooks/useAdminUsers.ts
Normal file
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
37
src/pages/Admin.tsx
Normal file
37
src/pages/Admin.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Admin Panel</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">User management, usage analytics, and pricing configuration.</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="usage">Usage</TabsTrigger>
|
||||
<TabsTrigger value="pricing">Pricing</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage">
|
||||
<UsageTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pricing">
|
||||
<PricingTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue