Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions backend/config/startup_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]


Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
157 changes: 157 additions & 0 deletions backend/routes/admin.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()

Comment thread
DevanshuNEU marked this conversation as resolved.
# 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", [])
)
Comment thread
DevanshuNEU marked this conversation as resolved.
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
Comment thread
DevanshuNEU marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)}
Comment thread
DevanshuNEU marked this conversation as resolved.


# -- 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,
},
}
2 changes: 2 additions & 0 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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 (
<DashboardLayout>
<Routes>
<Route index element={<DashboardHome />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</DashboardLayout>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom'
import {
FolderGit2,
BookOpen,
Shield,
ChevronLeft,
ChevronRight,
ExternalLink,
Expand All @@ -24,6 +25,7 @@ interface NavItem {

const mainNavItems: NavItem[] = [
{ name: 'Repositories', href: '/dashboard', icon: <FolderGit2 className="w-5 h-5" /> },
{ name: 'Admin', href: '/dashboard/admin', icon: <Shield className="w-5 h-5" /> },
]

const bottomNavItems: NavItem[] = [
Expand Down
Loading