diff --git a/backend/app/auth/quart_jwt.py b/backend/app/auth/quart_jwt.py index 947469cc..44321433 100755 --- a/backend/app/auth/quart_jwt.py +++ b/backend/app/auth/quart_jwt.py @@ -31,14 +31,15 @@ class QuartJWTError(Exception): pass -def create_access_token(identity: str, expires_delta: Optional[timedelta] = None) -> str: +def create_access_token(identity: str, expires_delta: Optional[timedelta] = None, token_version: int = 0) -> str: """ Create a JWT access token. - + Args: identity: User identifier (usually user ID) expires_delta: Optional expiration time override - + token_version: Token version for invalidation support + Returns: JWT token string """ @@ -46,14 +47,15 @@ def create_access_token(identity: str, expires_delta: Optional[timedelta] = None expire = datetime.now(timezone.utc) + expires_delta else: expire = datetime.now(timezone.utc) + JWT_ACCESS_TOKEN_EXPIRES - + payload = { 'sub': identity, # Subject (user ID) 'exp': expire, 'iat': datetime.now(timezone.utc), - 'type': 'access' + 'type': 'access', + 'tv': token_version, } - + return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) @@ -148,6 +150,20 @@ def jwt_required(optional: bool = False): # Store user ID in request context g.current_user_id = user_id + # Token-version check — invalidates old tokens after password reset or disable + try: + tv_in_token = payload.get("tv", 0) + from app.models.user import User as _User + current_tv = await _User.get_token_version(user_id) + if tv_in_token < current_tv: + return Response( + json.dumps({"error": "Token invalidated"}), + status=401, + mimetype="application/json", + ) + except Exception: + pass # Non-fatal — a DB failure must not block auth + # Propagate user_id into the LLM usage ContextVar for this request. # Each Quart request runs in its own asyncio Task, so setting the ContextVar # here is request-scoped. Child tasks (create_task) and thread submissions diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8501ee14..ccedb8cc 100755 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -73,6 +73,22 @@ class User: {"$set": fields} ) return result.matched_count > 0 + + @staticmethod + async def bump_token_version(user_id) -> int: + db = await get_db() + result = await db.users.find_one_and_update( + {"_id": ObjectId(user_id)}, + {"$inc": {"token_version": 1}}, + return_document=True + ) + return result.get("token_version", 1) if result else 1 + + @staticmethod + async def get_token_version(user_id) -> int: + db = await get_db() + doc = await db.users.find_one({"_id": ObjectId(user_id)}, {"token_version": 1}) + return doc.get("token_version", 0) if doc else 0 def to_dict(self): return { diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index f4214000..52f7b4c3 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -139,6 +139,10 @@ async def update_user(user_id): if not updated: return jsonify({'error': 'User not found'}), 404 + # Bump token_version so existing JWTs are immediately invalidated + if fields.get('is_active') is False or 'role' in fields: + await User.bump_token_version(user_id) + 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 @@ -314,3 +318,150 @@ async def list_pricing(): }).sort([('model', 1), ('effective_from', -1)]) rows = await cursor.to_list(length=100) return jsonify({'pricing': make_serializable(rows)}), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# Users — extended +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/users', methods=['POST']) +@jwt_required() +@admin_required +async def create_user(): + """POST /api/admin/users — create a local (non-SSO) user.""" + import bcrypt as _bcrypt + data = await request.get_json(silent=True) or {} + username = (data.get('username') or '').strip() + email = (data.get('email') or '').strip() + password = (data.get('password') or '').strip() + role = data.get('role', 'user') + + if not username or not email or not password: + return jsonify({'error': 'username, email, password required'}), 400 + if role not in ('user', 'admin'): + return jsonify({'error': 'Invalid role. Must be user or admin'}), 400 + + db = await get_db() + if await db.users.find_one({'$or': [{'username': username}, {'email': email}]}): + return jsonify({'error': 'Username or email already exists'}), 409 + + pw_hash = _bcrypt.hashpw(password.encode(), _bcrypt.gensalt()).decode() + now = datetime.now(timezone.utc) + doc = { + 'username': username, + 'email': email, + 'password_hash': pw_hash, + 'role': role, + 'is_active': True, + 'override_quota': False, + 'token_version': 0, + 'created_at': now, + 'updated_at': now, + } + result = await db.users.insert_one(doc) + doc['_id'] = result.inserted_id + logger.info(f"Admin created user {username} ({email})") + return jsonify(_safe_user(make_serializable(doc))), 201 + + +@admin_bp.route('/users//reset-password', methods=['POST']) +@jwt_required() +@admin_required +async def reset_password(user_id): + """POST /api/admin/users//reset-password""" + import bcrypt as _bcrypt + data = await request.get_json(silent=True) or {} + new_password = (data.get('password') or '').strip() + if not new_password or len(new_password) < 8: + return jsonify({'error': 'Password must be at least 8 characters'}), 400 + + pw_hash = _bcrypt.hashpw(new_password.encode(), _bcrypt.gensalt()).decode() + db = await get_db() + try: + result = await db.users.update_one( + {'_id': ObjectId(user_id)}, + {'$set': {'password_hash': pw_hash}} + ) + except Exception: + return jsonify({'error': 'Invalid user ID'}), 400 + + if result.matched_count == 0: + return jsonify({'error': 'User not found'}), 404 + + await User.bump_token_version(user_id) + logger.info(f"Admin reset password for user {user_id}") + return jsonify({'ok': True}), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# Pricing — extended +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/pricing', methods=['POST']) +@jwt_required() +@admin_required +async def create_pricing(): + """POST /api/admin/pricing — insert a new pricing row.""" + data = await request.get_json(silent=True) or {} + model = (data.get('model') or '').strip() + provider = (data.get('provider') or '').strip() + tiers = data.get('tiers', []) + if not model or not provider or not tiers: + return jsonify({'error': 'model, provider, tiers required'}), 400 + + now = datetime.now(timezone.utc) + expire_current = bool(data.get('expire_current', False)) + db = await get_db() + if expire_current: + await db.model_pricing.update_many( + {'model': model, 'effective_until': None}, + {'$set': {'effective_until': now}}, + ) + doc = { + 'model': model, + 'provider': provider, + 'currency': 'USD', + 'tiers': tiers, + 'effective_from': now, + 'effective_until': None, + 'notes': data.get('notes', ''), + } + result = await db.model_pricing.insert_one(doc) + doc['_id'] = result.inserted_id + logger.info(f"Admin created pricing row for model {model}") + return jsonify(make_serializable(doc)), 201 + + +# ───────────────────────────────────────────────────────────────────────────── +# Focus Groups (admin view) +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/focus-groups', methods=['GET']) +@jwt_required() +@admin_required +async def list_focus_groups(): + """GET /api/admin/focus-groups?skip=&limit= — list all focus groups with cost totals.""" + skip = max(0, int(request.args.get('skip', 0))) + limit = min(200, max(1, int(request.args.get('limit', 50)))) + db = await get_db() + cursor = db.focus_groups.find( + {}, + {'name': 1, 'date': 1, 'status': 1, 'llm_model': 1, 'quota': 1}, + ).sort('date', -1).skip(skip).limit(limit) + fgs = await cursor.to_list(length=limit) + + result = [] + for fg in fgs: + fg_id = str(fg['_id']) + pipeline = [ + {'$match': {'focus_group_id': fg_id}}, + {'$group': {'_id': None, 'total': {'$sum': '$cost_usd.total'}, 'calls': {'$sum': 1}}}, + ] + agg = await db.usage_events.aggregate(pipeline).to_list(1) + fg['cost_total'] = agg[0]['total'] if agg else 0 + fg['call_count'] = agg[0]['calls'] if agg else 0 + result.append(fg) + + result.sort(key=lambda x: x['cost_total'], reverse=True) + total = await db.focus_groups.count_documents({}) + return jsonify({'focus_groups': make_serializable(result), 'total': total}), 200 diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 818fe2e1..6119e68c 100755 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -60,8 +60,9 @@ async def login(): if not User.check_password(user_data['password_hash'], password): return jsonify({"message": "Invalid username or password"}), 401 - # Generate access token - access_token = create_access_token(identity=str(user_data['_id'])) + # Generate access token (embed token_version for invalidation support) + tv = user_data.get("token_version", 0) + access_token = create_access_token(identity=str(user_data['_id']), token_version=tv) return jsonify({ "message": "Login successful", @@ -168,8 +169,9 @@ async def microsoft_login(): print(f"Error creating Microsoft user: {e}") return jsonify({"message": "Failed to create user account"}), 500 - # Generate our backend JWT access token - access_token = create_access_token(identity=str(existing_user['_id'])) + # Generate our backend JWT access token (embed token_version for invalidation support) + _tv = existing_user.get("token_version", 0) + access_token = create_access_token(identity=str(existing_user['_id']), token_version=_tv) # Return response in same format as local login return jsonify({ diff --git a/backend/app/services/autonomous_conversation_controller.py b/backend/app/services/autonomous_conversation_controller.py index 5c66aa53..e3cf9ba4 100755 --- a/backend/app/services/autonomous_conversation_controller.py +++ b/backend/app/services/autonomous_conversation_controller.py @@ -258,6 +258,33 @@ class AutonomousConversationController: } except Exception as e: + # Handle quota exhaustion — pause gracefully instead of erroring + try: + from app.models.quota import QuotaExceededError + if isinstance(e, QuotaExceededError): + self.logger.warning( + f"Quota exceeded for focus group {self.focus_group_id}: {e}" + ) + self.is_running = False + self.conversation_state = "paused_quota" + await FocusGroup.update(self.focus_group_id, {"status": "paused_quota"}) + try: + from app.websocket_manager_async import websocket_manager + await websocket_manager.emit_to_focus_group( + self.focus_group_id, + "quota_exceeded", + { + "scope": e.scope, + "limit_usd": e.limit_usd, + "used_usd": e.used_usd, + "focus_group_id": self.focus_group_id, + }, + ) + except Exception as _we: + self.logger.warning(f"Could not emit quota_exceeded WS event: {_we}") + return {"error": "quota_exceeded", "scope": e.scope} + except ImportError: + pass self.logger.error(f"Error in conversation loop: {str(e)}") self.conversation_state = "error" await self.stop_conversation("error") diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 74d839bd..60d3143e 100755 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -294,6 +294,18 @@ class LLMService: max_retries = 3 last_error = None + # Quota pre-flight — raises QuotaExceededError if over limit + try: + from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError + from app.services.llm_usage_context import current_context as _ctx + _c = _ctx() + await check_quota(_c.user_id, _c.focus_group_id) + except Exception as _qe: + from app.models.quota import QuotaExceededError as _QEE + if isinstance(_qe, _QEE): + raise + pass # Non-fatal: DB failures must not block LLM calls + actual_model = LLMService._resolve_model(model_name) provider = LLMService._get_model_provider(model_name) _start_time = time.monotonic() @@ -609,6 +621,18 @@ class LLMService: max_retries = 3 last_error = None + # Quota pre-flight — raises QuotaExceededError if over limit + try: + from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError + from app.services.llm_usage_context import current_context as _ctx + _c = _ctx() + await check_quota(_c.user_id, _c.focus_group_id) + except Exception as _qe: + from app.models.quota import QuotaExceededError as _QEE + if isinstance(_qe, _QEE): + raise + pass # Non-fatal: DB failures must not block LLM calls + actual_model = LLMService._resolve_model(model_name) provider = LLMService._get_model_provider(model_name) @@ -805,11 +829,23 @@ class LLMService: LLMServiceError: If there's an issue with generation """ logger = logging.getLogger(__name__) - + + # Quota pre-flight — raises QuotaExceededError if over limit + try: + from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError + from app.services.llm_usage_context import current_context as _ctx + _c = _ctx() + await check_quota(_c.user_id, _c.focus_group_id) + except Exception as _qe: + from app.models.quota import QuotaExceededError as _QEE + if isinstance(_qe, _QEE): + raise + pass # Non-fatal: DB failures must not block LLM calls + # Separate text and image content from the conversation context text_context_parts = [] image_parts = [] - + print(f"🎯 Processing {len(conversation_context)} context items for LLM") for item in conversation_context: diff --git a/backend/scripts/backfill_usage.py b/backend/scripts/backfill_usage.py new file mode 100644 index 00000000..9598096d --- /dev/null +++ b/backend/scripts/backfill_usage.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +Backfill usage_events from existing focus-group messages and personas. + +Creates estimated usage_event docs (is_estimated=True) so the admin dashboard +can show historical cost data for sessions that pre-date the usage tracking system. + +Idempotent: skips documents that already have an estimated event in the collection. + +Usage: + cd backend + python scripts/backfill_usage.py [--dry-run] + +Environment: + MONGO_URI — connection string (falls back to localhost:27017 without auth) + DB_NAME — database name (default: semblance_db) +""" + +import argparse +import os +import sys +from datetime import datetime, timezone +from pymongo import MongoClient + +# ───────────────────────────────────────────────────────────────────────────── +# Token estimation helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _estimate_tokens(text: str, model: str) -> dict: + """Estimate prompt/completion tokens for a piece of text.""" + if not text: + return {"prompt": 0, "completion": 0} + + # Try tiktoken for OpenAI models, fall back to char-based estimate + if model and ("gpt" in model.lower() or "openai" in model.lower()): + try: + import tiktoken + enc = tiktoken.encoding_for_model("gpt-4") + n = len(enc.encode(text)) + return {"prompt": n, "completion": 0} + except Exception: + pass + + # Gemini / unknown: ~3.8 chars per token + n = max(1, int(len(text) / 3.8)) + return {"prompt": n, "completion": 0} + + +def _estimate_cost(prompt_tokens: int, completion_tokens: int, model: str) -> float: + """Very rough cost estimate in USD (used only for backfill estimates).""" + # Approximate per-million-token prices for common models + rate_per_m = { + "gemini": (0.35, 1.05), # input, output USD/1M tokens + "gpt-4": (30.00, 60.00), + "gpt-3": (0.50, 1.50), + } + key = "gemini" + if model: + m = model.lower() + if "gpt-4" in m or "gpt-5" in m: + key = "gpt-4" + elif "gpt-3" in m: + key = "gpt-3" + + input_rate, output_rate = rate_per_m[key] + cost = (prompt_tokens / 1_000_000) * input_rate + (completion_tokens / 1_000_000) * output_rate + return round(cost, 8) + + +# ───────────────────────────────────────────────────────────────────────────── +# DB connection (sync PyMongo) +# ───────────────────────────────────────────────────────────────────────────── + +def connect(): + mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017") + db_name = os.environ.get("DB_NAME", "semblance_db") + try: + client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000) + client.admin.command("ping") + print(f"Connected to MongoDB: {db_name}") + return client[db_name] + except Exception as e: + print(f"ERROR: Could not connect to MongoDB: {e}") + sys.exit(1) + + +# ───────────────────────────────────────────────────────────────────────────── +# Backfill focus-group messages +# ───────────────────────────────────────────────────────────────────────────── + +def backfill_messages(db, dry_run: bool) -> int: + """Walk all focus groups and create estimated usage events for messages.""" + created = 0 + focus_groups = list(db.focus_groups.find({})) + print(f"\n[messages] Found {len(focus_groups)} focus groups to process") + + for fg in focus_groups: + fg_id = str(fg["_id"]) + fg_model = fg.get("llm_model") or "gemini-3.1-pro-preview" + messages = fg.get("messages", []) + + for msg in messages: + msg_id = str(msg.get("id") or msg.get("_id") or "") + if not msg_id: + continue + + # Idempotent: skip if an estimated event already exists for this message + existing = db.usage_events.find_one({ + "source_message_id": msg_id, + "is_estimated": True, + }) + if existing: + continue + + text = msg.get("content") or "" + tokens = _estimate_tokens(text, fg_model) + # For responses we add a rough output token estimate + tokens["completion"] = max(1, int(len(text) / 5.0)) + cost = _estimate_cost(tokens["prompt"], tokens["completion"], fg_model) + + ts = msg.get("timestamp") + if isinstance(ts, str): + try: + ts = datetime.fromisoformat(ts) + except Exception: + ts = None + ts = ts or fg.get("date") or datetime.now(timezone.utc) + + event = { + "ts": ts, + "provider": "gemini" if "gemini" in fg_model.lower() else "openai", + "model": fg_model, + "feature": "autonomous_conversation", + "user_id": str(fg.get("user_id") or ""), + "focus_group_id": fg_id, + "persona_id": str(msg.get("personaId") or msg.get("persona_id") or ""), + "prompt_tokens": tokens["prompt"], + "completion_tokens": tokens["completion"], + "cached_tokens": 0, + "reasoning_tokens": 0, + "cost_usd": { + "input": round(cost * 0.4, 8), + "output": round(cost * 0.6, 8), + "total": cost, + }, + "duration_ms": 0, + "retry_count": 0, + "status": "estimated", + "is_estimated": True, + "source_message_id": msg_id, + } + + if not dry_run: + db.usage_events.insert_one(event) + created += 1 + + print(f"[messages] {'Would create' if dry_run else 'Created'} {created} estimated usage events") + return created + + +# ───────────────────────────────────────────────────────────────────────────── +# Backfill persona generation +# ───────────────────────────────────────────────────────────────────────────── + +def backfill_personas(db, dry_run: bool) -> int: + """Walk all personas and create an estimated usage event for narrative generation.""" + created = 0 + personas = list(db.personas.find({})) + print(f"\n[personas] Found {len(personas)} personas to process") + + for persona in personas: + persona_id = str(persona["_id"]) + narrative = persona.get("narrative") or "" + if not narrative: + continue # No narrative to estimate from — skip + + # Idempotent check + existing = db.usage_events.find_one({ + "persona_id": persona_id, + "feature": "persona_generate", + "is_estimated": True, + }) + if existing: + continue + + model = "gemini-3.1-pro-preview" # default; personas are usually generated via default model + tokens = _estimate_tokens(narrative, model) + tokens["completion"] = max(1, int(len(narrative) / 4.0)) + cost = _estimate_cost(tokens["prompt"], tokens["completion"], model) + + ts = persona.get("created_at") or persona.get("updatedAt") or datetime.now(timezone.utc) + if isinstance(ts, str): + try: + ts = datetime.fromisoformat(ts) + except Exception: + ts = datetime.now(timezone.utc) + + event = { + "ts": ts, + "provider": "gemini", + "model": model, + "feature": "persona_generate", + "user_id": str(persona.get("user_id") or ""), + "focus_group_id": str(persona.get("focus_group_id") or ""), + "persona_id": persona_id, + "prompt_tokens": tokens["prompt"], + "completion_tokens": tokens["completion"], + "cached_tokens": 0, + "reasoning_tokens": 0, + "cost_usd": { + "input": round(cost * 0.4, 8), + "output": round(cost * 0.6, 8), + "total": cost, + }, + "duration_ms": 0, + "retry_count": 0, + "status": "estimated", + "is_estimated": True, + } + + if not dry_run: + db.usage_events.insert_one(event) + created += 1 + + print(f"[personas] {'Would create' if dry_run else 'Created'} {created} estimated usage events") + return created + + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Backfill usage_events from existing data") + parser.add_argument("--dry-run", action="store_true", help="Preview what would be created without writing") + args = parser.parse_args() + + if args.dry_run: + print("=== DRY RUN — no data will be written ===\n") + + db = connect() + + total = 0 + total += backfill_messages(db, args.dry_run) + total += backfill_personas(db, args.dry_run) + + print(f"\n{'[DRY RUN] ' if args.dry_run else ''}Backfill complete — {total} events total") + + +if __name__ == "__main__": + main() diff --git a/src/components/admin/FocusGroupsTab.tsx b/src/components/admin/FocusGroupsTab.tsx new file mode 100644 index 00000000..234f8ace --- /dev/null +++ b/src/components/admin/FocusGroupsTab.tsx @@ -0,0 +1,58 @@ +import { useAdminFocusGroups } from '@/hooks/useAdminFocusGroups'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Loader2 } from 'lucide-react'; + +export default function FocusGroupsTab() { + const { data, isLoading } = useAdminFocusGroups(); + const fgs = data?.focus_groups ?? []; + + return ( +
+ {isLoading ? ( +
+ ) : ( +
+ + + + Name + Date + Model + Status + Total Cost + Calls + + + + {fgs.length === 0 && ( + + No focus groups + + )} + {fgs.map((fg: any) => ( + + {fg.name} + + {fg.date ? new Date(fg.date).toLocaleDateString() : '—'} + + {fg.llm_model ?? '—'} + + + {fg.status ?? 'active'} + + + ${(fg.cost_total ?? 0).toFixed(4)} + {fg.call_count ?? 0} + + ))} + +
+
+ )} +
+ ); +} diff --git a/src/components/admin/PricingTab.tsx b/src/components/admin/PricingTab.tsx index 18da72da..09285fc6 100644 --- a/src/components/admin/PricingTab.tsx +++ b/src/components/admin/PricingTab.tsx @@ -1,7 +1,13 @@ -import { useAdminPricing } from '@/hooks/useAdminPricing'; +import { useState } from 'react'; +import { useAdminPricing, useCreatePricing } 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'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Loader2, Plus } from 'lucide-react'; interface PricingTier { threshold_input_tokens: number; @@ -23,11 +29,51 @@ interface PricingRow { export default function PricingTab() { const { data, isLoading } = useAdminPricing(); + const createPricing = useCreatePricing(); const rows: PricingRow[] = data?.pricing ?? []; + const [showDialog, setShowDialog] = useState(false); + const [model, setModel] = useState(''); + const [provider, setProvider] = useState('gemini'); + const [inputPerMtok, setInputPerMtok] = useState(''); + const [outputPerMtok, setOutputPerMtok] = useState(''); + const [cachedInputPerMtok, setCachedInputPerMtok] = useState(''); + const [expireCurrent, setExpireCurrent] = useState(true); + + const handleCreate = () => { + const payload: any = { + model, + provider, + tiers: [{ + threshold_input_tokens: 0, + input_per_mtok: parseFloat(inputPerMtok), + output_per_mtok: parseFloat(outputPerMtok), + cached_input_per_mtok: cachedInputPerMtok ? parseFloat(cachedInputPerMtok) : null, + }], + expire_current: expireCurrent, + }; + createPricing.mutate(payload, { + onSuccess: () => { + setShowDialog(false); + setModel(''); + setProvider('gemini'); + setInputPerMtok(''); + setOutputPerMtok(''); + setCachedInputPerMtok(''); + setExpireCurrent(true); + }, + }); + }; + return (
-

Active pricing rows used for cost calculations. New rows can be added via API.

+
+

Active pricing rows used for cost calculations.

+ +
{isLoading ? (
@@ -91,6 +137,92 @@ export default function PricingTab() {
)} + + {/* New Price Dialog */} + !open && setShowDialog(false)}> + + + New Pricing Row + +
+
+ + setModel(e.target.value)} + /> +
+
+ + +
+
+ + setInputPerMtok(e.target.value)} + /> +
+
+ + setOutputPerMtok(e.target.value)} + /> +
+
+ + setCachedInputPerMtok(e.target.value)} + /> +
+
+ setExpireCurrent(e.target.checked)} + className="h-4 w-4 rounded border-slate-300" + /> + +
+
+ + + + +
+
); } diff --git a/src/components/admin/UsersTab.tsx b/src/components/admin/UsersTab.tsx index b11229c3..9e199fdb 100644 --- a/src/components/admin/UsersTab.tsx +++ b/src/components/admin/UsersTab.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser } from '@/hooks/useAdminUsers'; +import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser, useCreateUser, useResetPassword } 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'; @@ -7,7 +7,7 @@ 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'; +import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus } from 'lucide-react'; interface User { _id: string; @@ -27,11 +27,21 @@ export default function UsersTab() { const [editRole, setEditRole] = useState('user'); const [editQuota, setEditQuota] = useState(''); const [editOverride, setEditOverride] = useState(false); + const [resetPassword, setResetPassword] = useState(''); + + // Create user dialog state + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newUsername, setNewUsername] = useState(''); + const [newEmail, setNewEmail] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newRole, setNewRole] = useState('user'); const { data, isLoading } = useAdminUsers({ q: search, role: roleFilter || undefined }); const updateUser = useUpdateUser(); const disableUser = useDisableUser(); const enableUser = useEnableUser(); + const createUser = useCreateUser(); + const resetPasswordMutation = useResetPassword(); const users: User[] = data?.users || []; @@ -40,6 +50,29 @@ export default function UsersTab() { setEditRole(u.role); setEditQuota(u.quota?.monthly_usd?.toString() ?? ''); setEditOverride(u.override_quota ?? false); + setResetPassword(''); + }; + + const handleCreate = () => { + createUser.mutate( + { username: newUsername, email: newEmail, password: newPassword, role: newRole }, + { + onSuccess: () => { + setShowCreateDialog(false); + setNewUsername(''); + setNewEmail(''); + setNewPassword(''); + setNewRole('user'); + }, + } + ); + }; + + const handleResetPassword = () => { + if (!editUser || !resetPassword) return; + resetPasswordMutation.mutate({ id: editUser._id, password: resetPassword }, { + onSuccess: () => setResetPassword(''), + }); }; const handleSave = () => { @@ -78,6 +111,10 @@ export default function UsersTab() { User + {/* Table */} @@ -196,6 +233,25 @@ export default function UsersTab() { Override quota (bypass spending limit) +
+ +
+ setResetPassword(e.target.value)} + /> + +
+
@@ -206,6 +262,65 @@ export default function UsersTab() { + + {/* Create User Dialog */} + !open && setShowCreateDialog(false)}> + + + New User + +
+
+ + setNewUsername(e.target.value)} + /> +
+
+ + setNewEmail(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + +
+
+ + + + +
+
); } diff --git a/src/hooks/useAdminFocusGroups.ts b/src/hooks/useAdminFocusGroups.ts new file mode 100644 index 00000000..0c261079 --- /dev/null +++ b/src/hooks/useAdminFocusGroups.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '@/lib/api'; + +export function useAdminFocusGroups(params?: { skip?: number; limit?: number }) { + return useQuery({ + queryKey: ['admin', 'focus-groups', params], + queryFn: () => adminApi.listFocusGroups(params).then(r => r.data), + staleTime: 60_000, + }); +} diff --git a/src/hooks/useAdminPricing.ts b/src/hooks/useAdminPricing.ts index 833ce197..7d7cc7f8 100644 --- a/src/hooks/useAdminPricing.ts +++ b/src/hooks/useAdminPricing.ts @@ -1,5 +1,6 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { adminApi } from '@/lib/api'; +import { toast } from 'sonner'; export function useAdminPricing() { return useQuery({ @@ -8,3 +9,15 @@ export function useAdminPricing() { staleTime: 300_000, // 5 min — pricing rarely changes }); } + +export function useCreatePricing() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: any) => adminApi.createPricing(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'pricing'] }); + toast.success('Pricing row created'); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to create pricing'), + }); +} diff --git a/src/hooks/useAdminUsers.ts b/src/hooks/useAdminUsers.ts index e3ad9867..ae2f00b4 100644 --- a/src/hooks/useAdminUsers.ts +++ b/src/hooks/useAdminUsers.ts @@ -59,3 +59,27 @@ export function useEnableUser() { }, }); } + +export function useCreateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { username: string; email: string; password: string; role?: string }) => + adminApi.createUser(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'users'] }); + toast.success('User created'); + }, + onError: (err: any) => { + toast.error(err?.response?.data?.error || 'Failed to create user'); + }, + }); +} + +export function useResetPassword() { + return useMutation({ + mutationFn: ({ id, password }: { id: string; password: string }) => + adminApi.resetPassword(id, password), + onSuccess: () => toast.success('Password reset — existing sessions invalidated'), + onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to reset password'), + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 87f96785..3bc000df 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -83,6 +83,14 @@ export const dispatchAuthError = (details?: AuthErrorDetail) => { api.interceptors.response.use( (response) => response, (error) => { + // Handle 402 (Quota Exceeded) + if (error.response && error.response.status === 402) { + const data = error.response.data; + window.dispatchEvent(new CustomEvent('quota_exceeded', { + detail: { scope: data.scope, limit_usd: data.limit_usd, used_usd: data.used_usd } + })); + } + // Handle 401 (Unauthorized) - dispatch event instead of redirect if (error.response && error.response.status === 401) { // Check if this is a persona-related request - these are handled separately @@ -728,6 +736,19 @@ export const adminApi = { // Pricing listPricing: () => api.get('/admin/pricing'), + + createPricing: (data: any) => api.post('/admin/pricing', data), + + // Users — create + reset password + createUser: (data: { username: string; email: string; password: string; role?: string }) => + api.post('/admin/users', data), + + resetPassword: (id: string, password: string) => + api.post(`/admin/users/${id}/reset-password`, { password }), + + // Focus Groups (admin view) + listFocusGroups: (params?: { skip?: number; limit?: number }) => + api.get('/admin/focus-groups', { params }), }; export const usageApi = { diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 1c3178f1..83e06fd8 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -2,6 +2,7 @@ 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'; +import FocusGroupsTab from '@/components/admin/FocusGroupsTab'; export default function Admin() { return ( @@ -17,6 +18,7 @@ export default function Admin() { Users Usage Pricing + Focus Groups @@ -30,6 +32,10 @@ export default function Admin() { + + + + diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index ee048917..4c333eac 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -31,7 +31,8 @@ import NotesPanel from '@/components/focus-group-session/NotesPanel'; import QuickNoteModal from '@/components/focus-group-session/QuickNoteModal'; import { FocusGroup, Message, Theme, Note, QuoteData, ModeEvent } from '@/components/focus-group-session/types'; import { Persona } from '@/types/persona'; -import api, { focusGroupsApi, personasApi, focusGroupAiApi } from '@/lib/api'; +import api, { focusGroupsApi, personasApi, focusGroupAiApi, adminApi } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; import ProgressModal from '@/components/ui/ProgressModal'; @@ -47,7 +48,7 @@ import { const FocusGroupSession = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { token } = useAuth(); + const { token, user } = useAuth(); const [messages, setMessages] = useState([]); const [modeEvents, setModeEvents] = useState([]); @@ -106,6 +107,19 @@ const FocusGroupSession = () => { isLoading?: boolean; }>({ isOpen: false }); + // Quota exceeded banner state + const [quotaExceeded, setQuotaExceeded] = useState<{ scope: string; limit_usd: number; used_usd: number } | null>(null); + + // Admin-only: fetch focus group cost summary + const { data: fgCostData } = useQuery({ + queryKey: ['admin', 'fg-cost', id], + queryFn: () => adminApi.usageSummary({ focus_group_id: id, group_by: 'focus_group' }).then(r => r.data), + staleTime: 60_000, + enabled: !!id && user?.role === 'admin', + }); + const fgCostTotal = fgCostData?.totals?.total_cost ?? 0; + const fgTokensTotal = (fgCostData?.totals?.prompt_tokens ?? 0) + (fgCostData?.totals?.completion_tokens ?? 0); + // Track the last known AI status from API to avoid false positive changes const lastAiStatusRef = useRef(false); @@ -140,6 +154,16 @@ const FocusGroupSession = () => { return token || ''; }, [token]); + // Listen for quota_exceeded events dispatched by the API interceptor + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + setQuotaExceeded(detail); + }; + window.addEventListener('quota_exceeded', handler); + return () => window.removeEventListener('quota_exceeded', handler); + }, []); + // Initialize singleton socket (GPT-5 fix: avoid useMemo issues) useEffect(() => { if (useWebSocketEnabled) { @@ -1855,13 +1879,20 @@ const FocusGroupSession = () => {
- {focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' : + {focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' : focusGroup.llm_model === 'gpt-5.2' ? 'GPT-5.2' : 'Gemini 3 Pro'}
+ {user?.role === 'admin' && fgCostTotal > 0 && ( +
+ + ${fgCostTotal.toFixed(4)} • {(fgTokensTotal / 1000).toFixed(1)}k tok + +
+ )} - +
+ {/* Quota exceeded banner */} + {quotaExceeded && ( +
+ + Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used. + + +
+ )} + {/* Progress Modal for Key Themes Generation */}