Skip to content

Commit a604655

Browse files
authored
Merge pull request #273 from DevanshuNEU/feat/admin-tier-management
feat: admin dashboard -- user management and tier control (OPE-102)
2 parents dd08e69 + 08ad4f3 commit a604655

7 files changed

Lines changed: 376 additions & 0 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,7 @@ SEARCH_V2_ENABLED=true
6767
# Voyage AI (Optional -- code-specific embeddings for better search)
6868
# Get from: https://dash.voyageai.com/
6969
VOYAGE_API_KEY=
70+
71+
# Admin access (comma-separated emails)
72+
# Users with these emails can access /api/v1/admin/* routes
73+
ADMIN_EMAILS=admin@example.com

backend/config/startup_checks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
("DISCORD_FEEDBACK_WEBHOOK", "Discord webhook for feedback", "Feedback notifications disabled"),
3232
("ALLOW_ORIGIN_REGEX", "CORS regex for preview deploys", "Only explicit origins allowed"),
3333
("GITHUB_TOKEN", "GitHub API token for repo analysis", "Using unauthenticated rate limit (60/hr)"),
34+
("ADMIN_EMAILS", "Comma-separated admin emails", "Admin routes disabled"),
3435
]
3536

3637

backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from routes.search_v2 import router as search_v2_router
3535
from routes.github import router as github_router
3636
from routes.feedback import router as feedback_router
37+
from routes.admin import router as admin_router
3738
from routes.ws_playground import websocket_playground_index
3839
from routes.ws_repos import websocket_repo_indexing
3940

@@ -104,6 +105,7 @@ async def dispatch(self, request: Request, call_next):
104105
app.include_router(search_v2_router, prefix=API_PREFIX)
105106
app.include_router(github_router, prefix=API_PREFIX)
106107
app.include_router(feedback_router, prefix=API_PREFIX)
108+
app.include_router(admin_router, prefix=API_PREFIX)
107109

108110
# WebSocket endpoints (versioned)
109111
app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index)

backend/routes/admin.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Admin routes -- user management, tier control.
2+
3+
Protected by admin email check. Configure ADMIN_EMAILS env var
4+
(comma-separated) to grant access.
5+
"""
6+
import os
7+
from fastapi import APIRouter, HTTPException, Depends
8+
from pydantic import BaseModel
9+
10+
from dependencies import redis_client
11+
from middleware.auth import require_auth, AuthContext
12+
from services.observability import logger
13+
from services.supabase_service import get_supabase_service
14+
from services.user_limits import UserTier, TIER_LIMITS
15+
16+
router = APIRouter(prefix="/admin", tags=["Admin"])
17+
18+
_VALID_TIERS = {t.value for t in UserTier}
19+
20+
ADMIN_EMAILS = set(
21+
e.strip().lower()
22+
for e in os.getenv("ADMIN_EMAILS", "").split(",")
23+
if e.strip()
24+
)
25+
26+
27+
def require_admin(auth: AuthContext = Depends(require_auth)) -> AuthContext:
28+
"""Dependency that ensures the caller is an admin."""
29+
if not auth.email or auth.email.lower() not in ADMIN_EMAILS:
30+
raise HTTPException(status_code=403, detail="Admin access required")
31+
return auth
32+
33+
34+
# -- Users -------------------------------------------------------------------
35+
36+
@router.get("/users")
37+
def list_users(auth: AuthContext = Depends(require_admin)) -> dict:
38+
"""List all users with tier, repo count, and last sign-in."""
39+
sb = get_supabase_service()
40+
41+
# Get user profiles (tier info)
42+
profiles = {}
43+
try:
44+
result = sb.client.table("user_profiles").select("*").execute()
45+
for p in result.data or []:
46+
profiles[p.get("user_id")] = p
47+
except Exception as e:
48+
logger.warning("Failed to fetch user_profiles", error=str(e))
49+
50+
# Get repo counts per user
51+
repo_counts: dict[str, int] = {}
52+
try:
53+
result = sb.client.table("repositories").select("user_id").execute()
54+
for r in result.data or []:
55+
uid = r.get("user_id")
56+
if uid:
57+
repo_counts[uid] = repo_counts.get(uid, 0) + 1
58+
except Exception as e:
59+
logger.warning("Failed to fetch repo counts", error=str(e))
60+
61+
# Get auth users via Supabase admin API
62+
try:
63+
auth_response = sb.client.auth.admin.list_users()
64+
raw_users = (
65+
auth_response
66+
if isinstance(auth_response, list)
67+
else getattr(auth_response, "users", [])
68+
)
69+
except Exception as e:
70+
logger.error("Failed to list auth users", error=str(e))
71+
raise HTTPException(
72+
status_code=502, detail="Failed to fetch users from auth provider"
73+
)
74+
75+
users = []
76+
for u in raw_users:
77+
uid = u.id if hasattr(u, "id") else u.get("id")
78+
email = u.email if hasattr(u, "email") else u.get("email", "")
79+
created = u.created_at if hasattr(u, "created_at") else u.get("created_at", "")
80+
last_sign_in = (
81+
u.last_sign_in_at
82+
if hasattr(u, "last_sign_in_at")
83+
else u.get("last_sign_in_at", "")
84+
)
85+
meta = (
86+
u.user_metadata
87+
if hasattr(u, "user_metadata")
88+
else u.get("user_metadata", {})
89+
) or {} # coalesce None to empty dict
90+
91+
profile = profiles.get(uid, {})
92+
raw_tier = profile.get("tier", meta.get("tier", "free"))
93+
tier = raw_tier if raw_tier in _VALID_TIERS else UserTier.FREE.value
94+
95+
users.append({
96+
"id": uid,
97+
"email": email,
98+
"tier": tier,
99+
"repo_count": repo_counts.get(uid, 0),
100+
"created_at": str(created) if created else None,
101+
"last_sign_in": str(last_sign_in) if last_sign_in else None,
102+
})
103+
104+
logger.info("Admin listed users", count=len(users), admin=auth.email)
105+
return {"users": users, "total": len(users)}
106+
107+
108+
# -- Tier management ---------------------------------------------------------
109+
110+
class UpdateTierRequest(BaseModel):
111+
tier: UserTier
112+
113+
114+
@router.patch("/users/{user_id}/tier")
115+
def update_user_tier(
116+
user_id: str,
117+
request: UpdateTierRequest,
118+
auth: AuthContext = Depends(require_admin),
119+
) -> dict:
120+
"""Update a user's tier. Clears Redis cache so it takes effect immediately."""
121+
new_tier = request.tier
122+
123+
sb = get_supabase_service()
124+
125+
# Upsert into user_profiles
126+
try:
127+
sb.client.table("user_profiles").upsert(
128+
{"user_id": user_id, "tier": new_tier.value},
129+
on_conflict="user_id",
130+
).execute()
131+
except Exception as e:
132+
logger.error("Failed to update tier", user_id=user_id, error=str(e))
133+
raise HTTPException(status_code=500, detail="Failed to update tier")
134+
135+
# Clear Redis cache so new tier takes effect immediately
136+
if redis_client:
137+
try:
138+
redis_client.delete(f"user:tier:{user_id}")
139+
except Exception as e:
140+
logger.warning("Failed to clear tier cache", user_id=user_id, error=str(e))
141+
142+
limits = TIER_LIMITS[new_tier]
143+
logger.info(
144+
"Admin updated user tier",
145+
admin=auth.email, user_id=user_id,
146+
new_tier=new_tier.value,
147+
)
148+
149+
return {
150+
"user_id": user_id,
151+
"tier": new_tier.value,
152+
"limits": {
153+
"max_repos": limits.max_repos,
154+
"max_files_per_repo": limits.max_files_per_repo,
155+
"max_functions_per_repo": limits.max_functions_per_repo,
156+
},
157+
}

frontend/src/components/Dashboard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { Routes, Route, Navigate } from 'react-router-dom'
22
import { DashboardLayout } from './dashboard/DashboardLayout'
33
import { DashboardHome } from './dashboard/DashboardHome'
44
import { SettingsPage } from '../pages/SettingsPage'
5+
import { AdminPage } from '../pages/AdminPage'
56

67
export function Dashboard() {
78
return (
89
<DashboardLayout>
910
<Routes>
1011
<Route index element={<DashboardHome />} />
1112
<Route path="settings" element={<SettingsPage />} />
13+
<Route path="admin" element={<AdminPage />} />
1214
<Route path="*" element={<Navigate to="/dashboard" replace />} />
1315
</Routes>
1416
</DashboardLayout>

frontend/src/components/dashboard/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom'
22
import {
33
FolderGit2,
44
BookOpen,
5+
Shield,
56
ChevronLeft,
67
ChevronRight,
78
ExternalLink,
@@ -24,6 +25,7 @@ interface NavItem {
2425

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

2931
const bottomNavItems: NavItem[] = [

0 commit comments

Comments
 (0)