diff --git a/.env.example b/.env.example index 2028a86..258fcb2 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,7 @@ SEARCH_V2_ENABLED=true # Voyage AI (Optional -- code-specific embeddings for better search) # Get from: https://dash.voyageai.com/ VOYAGE_API_KEY= + +# Admin access (comma-separated emails) +# Users with these emails can access /api/v1/admin/* routes +ADMIN_EMAILS=admin@example.com diff --git a/backend/config/startup_checks.py b/backend/config/startup_checks.py index 4d8510e..a18cef0 100644 --- a/backend/config/startup_checks.py +++ b/backend/config/startup_checks.py @@ -31,6 +31,7 @@ ("DISCORD_FEEDBACK_WEBHOOK", "Discord webhook for feedback", "Feedback notifications disabled"), ("ALLOW_ORIGIN_REGEX", "CORS regex for preview deploys", "Only explicit origins allowed"), ("GITHUB_TOKEN", "GitHub API token for repo analysis", "Using unauthenticated rate limit (60/hr)"), + ("ADMIN_EMAILS", "Comma-separated admin emails", "Admin routes disabled"), ] diff --git a/backend/main.py b/backend/main.py index 76465c0..6792c66 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,6 +34,7 @@ from routes.search_v2 import router as search_v2_router from routes.github import router as github_router from routes.feedback import router as feedback_router +from routes.admin import router as admin_router from routes.ws_playground import websocket_playground_index from routes.ws_repos import websocket_repo_indexing @@ -104,6 +105,7 @@ async def dispatch(self, request: Request, call_next): app.include_router(search_v2_router, prefix=API_PREFIX) app.include_router(github_router, prefix=API_PREFIX) app.include_router(feedback_router, prefix=API_PREFIX) +app.include_router(admin_router, prefix=API_PREFIX) # WebSocket endpoints (versioned) app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index) diff --git a/backend/routes/admin.py b/backend/routes/admin.py new file mode 100644 index 0000000..ee3df12 --- /dev/null +++ b/backend/routes/admin.py @@ -0,0 +1,157 @@ +"""Admin routes -- user management, tier control. + +Protected by admin email check. Configure ADMIN_EMAILS env var +(comma-separated) to grant access. +""" +import os +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel + +from dependencies import redis_client +from middleware.auth import require_auth, AuthContext +from services.observability import logger +from services.supabase_service import get_supabase_service +from services.user_limits import UserTier, TIER_LIMITS + +router = APIRouter(prefix="/admin", tags=["Admin"]) + +_VALID_TIERS = {t.value for t in UserTier} + +ADMIN_EMAILS = set( + e.strip().lower() + for e in os.getenv("ADMIN_EMAILS", "").split(",") + if e.strip() +) + + +def require_admin(auth: AuthContext = Depends(require_auth)) -> AuthContext: + """Dependency that ensures the caller is an admin.""" + if not auth.email or auth.email.lower() not in ADMIN_EMAILS: + raise HTTPException(status_code=403, detail="Admin access required") + return auth + + +# -- Users ------------------------------------------------------------------- + +@router.get("/users") +def list_users(auth: AuthContext = Depends(require_admin)) -> dict: + """List all users with tier, repo count, and last sign-in.""" + sb = get_supabase_service() + + # Get user profiles (tier info) + profiles = {} + try: + result = sb.client.table("user_profiles").select("*").execute() + for p in result.data or []: + profiles[p.get("user_id")] = p + except Exception as e: + logger.warning("Failed to fetch user_profiles", error=str(e)) + + # Get repo counts per user + repo_counts: dict[str, int] = {} + try: + result = sb.client.table("repositories").select("user_id").execute() + for r in result.data or []: + uid = r.get("user_id") + if uid: + repo_counts[uid] = repo_counts.get(uid, 0) + 1 + except Exception as e: + logger.warning("Failed to fetch repo counts", error=str(e)) + + # Get auth users via Supabase admin API + try: + auth_response = sb.client.auth.admin.list_users() + raw_users = ( + auth_response + if isinstance(auth_response, list) + else getattr(auth_response, "users", []) + ) + except Exception as e: + logger.error("Failed to list auth users", error=str(e)) + raise HTTPException( + status_code=502, detail="Failed to fetch users from auth provider" + ) + + users = [] + for u in raw_users: + uid = u.id if hasattr(u, "id") else u.get("id") + email = u.email if hasattr(u, "email") else u.get("email", "") + created = u.created_at if hasattr(u, "created_at") else u.get("created_at", "") + last_sign_in = ( + u.last_sign_in_at + if hasattr(u, "last_sign_in_at") + else u.get("last_sign_in_at", "") + ) + meta = ( + u.user_metadata + if hasattr(u, "user_metadata") + else u.get("user_metadata", {}) + ) or {} # coalesce None to empty dict + + profile = profiles.get(uid, {}) + raw_tier = profile.get("tier", meta.get("tier", "free")) + tier = raw_tier if raw_tier in _VALID_TIERS else UserTier.FREE.value + + users.append({ + "id": uid, + "email": email, + "tier": tier, + "repo_count": repo_counts.get(uid, 0), + "created_at": str(created) if created else None, + "last_sign_in": str(last_sign_in) if last_sign_in else None, + }) + + logger.info("Admin listed users", count=len(users), admin=auth.email) + return {"users": users, "total": len(users)} + + +# -- Tier management --------------------------------------------------------- + +class UpdateTierRequest(BaseModel): + tier: UserTier + + +@router.patch("/users/{user_id}/tier") +def update_user_tier( + user_id: str, + request: UpdateTierRequest, + auth: AuthContext = Depends(require_admin), +) -> dict: + """Update a user's tier. Clears Redis cache so it takes effect immediately.""" + new_tier = request.tier + + sb = get_supabase_service() + + # Upsert into user_profiles + try: + sb.client.table("user_profiles").upsert( + {"user_id": user_id, "tier": new_tier.value}, + on_conflict="user_id", + ).execute() + except Exception as e: + logger.error("Failed to update tier", user_id=user_id, error=str(e)) + raise HTTPException(status_code=500, detail="Failed to update tier") + + # Clear Redis cache so new tier takes effect immediately + if redis_client: + try: + redis_client.delete(f"user:tier:{user_id}") + except Exception as e: + logger.warning("Failed to clear tier cache", user_id=user_id, error=str(e)) + + limits = TIER_LIMITS[new_tier] + logger.info( + "Admin updated user tier", + admin=auth.email, user_id=user_id, + new_tier=new_tier.value, + ) + + return { + "user_id": user_id, + "tier": new_tier.value, + "limits": { + "max_repos": limits.max_repos, + "max_files_per_repo": limits.max_files_per_repo, + "max_functions_per_repo": limits.max_functions_per_repo, + }, + } diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 9f46503..4018716 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { DashboardLayout } from './dashboard/DashboardLayout' import { DashboardHome } from './dashboard/DashboardHome' import { SettingsPage } from '../pages/SettingsPage' +import { AdminPage } from '../pages/AdminPage' export function Dashboard() { return ( @@ -9,6 +10,7 @@ export function Dashboard() { } /> } /> + } /> } /> diff --git a/frontend/src/components/dashboard/Sidebar.tsx b/frontend/src/components/dashboard/Sidebar.tsx index 0753e33..be100f9 100644 --- a/frontend/src/components/dashboard/Sidebar.tsx +++ b/frontend/src/components/dashboard/Sidebar.tsx @@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom' import { FolderGit2, BookOpen, + Shield, ChevronLeft, ChevronRight, ExternalLink, @@ -24,6 +25,7 @@ interface NavItem { const mainNavItems: NavItem[] = [ { name: 'Repositories', href: '/dashboard', icon: }, + { name: 'Admin', href: '/dashboard/admin', icon: }, ] const bottomNavItems: NavItem[] = [ diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..96bac81 --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -0,0 +1,208 @@ +/** + * AdminPage -- user management and tier control for platform admins. + * + * Lists all users with their tier, repo count, and last sign-in. + * Admins can change any user's tier with one click. + */ + +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Shield, Users, ArrowUpCircle, ArrowDownCircle, Loader2, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { useAuth } from '@/contexts/AuthContext' +import { API_URL } from '@/config/api' +import { cn } from '@/lib/utils' + +interface AdminUser { + id: string + email: string + tier: string + repo_count: number + created_at: string | null + last_sign_in: string | null +} + +const TIERS = ['free', 'pro', 'enterprise'] as const + +const TIER_COLORS: Record = { + free: 'bg-muted text-muted-foreground', + pro: 'bg-primary/10 text-primary border-primary/20', + enterprise: 'bg-amber-500/10 text-amber-500 border-amber-500/20', +} + +export function AdminPage() { + const { session } = useAuth() + const queryClient = useQueryClient() + const [updating, setUpdating] = useState(null) + + const headers = { Authorization: `Bearer ${session?.access_token}` } + + const { data, isLoading: loading, error: queryError, refetch } = useQuery<{ users: AdminUser[] }>({ + queryKey: ['admin', 'users'], + queryFn: async () => { + const resp = await fetch(`${API_URL}/admin/users`, { headers }) + if (resp.status === 403) { + throw new Error('Admin access required. Your email is not in the ADMIN_EMAILS list.') + } + if (!resp.ok) throw new Error('Failed to fetch users') + return resp.json() + }, + enabled: !!session?.access_token, + }) + + const users = data?.users ?? [] + const error = queryError instanceof Error ? queryError.message : queryError ? 'Failed to load users' : null + + async function changeTier(userId: string, email: string, newTier: string) { + setUpdating(userId) + try { + const resp = await fetch(`${API_URL}/admin/users/${userId}/tier`, { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ tier: newTier }), + }) + if (!resp.ok) { + const err = await resp.json().catch(() => ({})) + throw new Error(err.detail || 'Failed to update tier') + } + await resp.json() + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + toast.success(`Updated ${email} to ${newTier}`) + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Update failed') + } finally { + setUpdating(null) + } + } + + if (error) { + return ( + + + {error} + refetch()}>Retry + + ) + } + + return ( + + + + + + + + Admin + User management and tier control + + + refetch()} disabled={loading}> + + Refresh + + + + + + {users.length} users + + + {loading ? ( + + + + ) : ( + + + + + Email + Tier + Repos + Joined + Actions + + + + {users.map((user) => ( + changeTier(user.id, user.email, tier)} + /> + ))} + + + + )} + + ) +} + + +function UserRow({ + user, + updating, + onChangeTier, +}: { + user: AdminUser + updating: boolean + onChangeTier: (tier: string) => void +}) { + const currentIdx = TIERS.indexOf(user.tier as typeof TIERS[number]) + const canUpgrade = currentIdx < TIERS.length - 1 + const canDowngrade = currentIdx > 0 + + const joinDate = user.created_at + ? new Date(user.created_at).toLocaleDateString() + : '--' + + return ( + + + {user.email} + + + + {user.tier} + + + {user.repo_count} + {joinDate} + + + {updating ? ( + + ) : ( + <> + onChangeTier(TIERS[currentIdx + 1])} + className="h-7 px-2 text-xs" + > + + Upgrade + + onChangeTier(TIERS[currentIdx - 1])} + className="h-7 px-2 text-xs text-muted-foreground" + > + + Downgrade + + > + )} + + + + ) +}
{error}
User management and tier control