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:
Vadym Samoilenko 2026-04-24 18:26:05 +01:00
parent 8c5146022a
commit 015e6cc5cc
14 changed files with 1077 additions and 3 deletions

View file

@ -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():

View file

@ -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
View 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

View file

@ -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 />} />

View file

@ -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

View 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}</>;
}

View 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">&gt;{(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>
);
}

View 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>
);
}

View 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>
);
}

View 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
});
}

View 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,
});
}

View 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');
},
});
}

View file

@ -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
View 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>
);
}