Skip to content

Commit efd28e5

Browse files
committed
feat: admin dashboard -- user management and tier control (OPE-102)
Backend (backend/routes/admin.py, 162 lines): - GET /admin/users: lists all users with tier, repo count, last sign-in Joins Supabase auth users + user_profiles + repo counts - PATCH /admin/users/{id}/tier: upgrade/downgrade user tier Upserts to user_profiles, clears Redis cache for immediate effect - Protected by ADMIN_EMAILS env var (email allowlist) Frontend (frontend/src/pages/AdminPage.tsx, 216 lines): - User table: email, tier badge (color-coded), repo count, join date - One-click upgrade/downgrade buttons per user - 403 error state with clear 'Admin access required' message - Refresh button, loading states, toast notifications Wiring: - Route at /dashboard/admin in Dashboard.tsx - Shield icon + 'Admin' link in Sidebar - admin_router registered in main.py at /api/v1/admin - ADMIN_EMAILS added to startup_checks and .env.example Set ADMIN_EMAILS=your@email.com on Railway to enable.
1 parent dd08e69 commit efd28e5

7 files changed

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

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)