From efd28e58e40f8ba8c0d8672f85d7bd6c3560c38f Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 1 Mar 2026 12:58:05 -0500 Subject: [PATCH 1/6] 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. --- .env.example | 4 + backend/config/startup_checks.py | 1 + backend/main.py | 2 + backend/routes/admin.py | 162 +++++++++++++ frontend/src/components/Dashboard.tsx | 2 + frontend/src/components/dashboard/Sidebar.tsx | 2 + frontend/src/pages/AdminPage.tsx | 216 ++++++++++++++++++ 7 files changed, 389 insertions(+) create mode 100644 backend/routes/admin.py create mode 100644 frontend/src/pages/AdminPage.tsx diff --git a/.env.example b/.env.example index 2028a86..f5828c5 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=devanshurajesh@gmail.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..84be8a3 --- /dev/null +++ b/backend/routes/admin.py @@ -0,0 +1,162 @@ +"""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 user_limits +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"]) + +ADMIN_EMAILS = set( + e.strip() + 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 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", {}) + ) + + profile = profiles.get(uid, {}) + tier = profile.get("tier", meta.get("tier", "free")) + + 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: str # free, pro, enterprise + + +@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.""" + try: + new_tier = UserTier(request.tier) + except ValueError: + valid = [t.value for t in UserTier] + raise HTTPException( + status_code=400, + detail=f"Invalid tier '{request.tier}'. Must be one of: {valid}", + ) + + 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 + from dependencies import redis_client + if redis_client: + try: + redis_client.delete(f"user:tier:{user_id}") + except Exception: + pass + + 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..d6308c5 --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -0,0 +1,216 @@ +/** + * 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, useEffect, useCallback } from 'react' +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 [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [updating, setUpdating] = useState(null) + const [error, setError] = useState(null) + + const headers = { Authorization: `Bearer ${session?.access_token}` } + + const fetchUsers = useCallback(async () => { + try { + setLoading(true) + setError(null) + const resp = await fetch(`${API_URL}/admin/users`, { headers }) + if (resp.status === 403) { + setError('Admin access required. Your email is not in the ADMIN_EMAILS list.') + return + } + if (!resp.ok) throw new Error('Failed to fetch users') + const data = await resp.json() + setUsers(data.users || []) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load users') + } finally { + setLoading(false) + } + }, [session?.access_token]) + + useEffect(() => { fetchUsers() }, [fetchUsers]) + + 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') + } + const result = await resp.json() + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, tier: result.tier } : u)), + ) + toast.success(`Updated ${email} to ${newTier}`) + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Update failed') + } finally { + setUpdating(null) + } + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ) + } + + return ( +
+
+
+
+ +
+
+

Admin

+

User management and tier control

+
+
+ +
+ +
+ + {users.length} users +
+ + {loading ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + changeTier(user.id, user.email, tier)} + /> + ))} + +
EmailTierReposJoinedActions
+
+ )} +
+ ) +} + + +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 ? ( + + ) : ( + <> + + + + )} +
+ + + ) +} From c469e4de4ccef64df315dab6549edd526b8075e9 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 1 Mar 2026 13:49:39 -0500 Subject: [PATCH 2/6] fix: address review -- unused import, React Query, redis import, email placeholder 1. Remove unused 'user_limits' import (CI lint failure) 2. Move redis_client import to module top, remove late import in function 3. Replace real email with admin@example.com in .env.example 4. Convert AdminPage from useEffect+fetch to React Query (useQuery) per CLAUDE.md: 'Use React Query for all server data fetching' - queryKey: ['admin', 'users'] - changeTier invalidates query instead of manual state update - refetch replaces fetchUsers callback Skipped: sync->async route conversion. Supabase client is sync, FastAPI correctly runs sync handlers in threadpool. Converting to async def with sync DB calls would block the event loop. flake8 passes. Build passes. --- .env.example | 2 +- backend/routes/admin.py | 3 +-- frontend/src/pages/AdminPage.tsx | 42 +++++++++++++------------------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index f5828c5..258fcb2 100644 --- a/.env.example +++ b/.env.example @@ -70,4 +70,4 @@ VOYAGE_API_KEY= # Admin access (comma-separated emails) # Users with these emails can access /api/v1/admin/* routes -ADMIN_EMAILS=devanshurajesh@gmail.com +ADMIN_EMAILS=admin@example.com diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 84be8a3..185d462 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel -from dependencies import user_limits +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 @@ -137,7 +137,6 @@ def update_user_tier( raise HTTPException(status_code=500, detail="Failed to update tier") # Clear Redis cache so new tier takes effect immediately - from dependencies import redis_client if redis_client: try: redis_client.delete(f"user:tier:{user_id}") diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index d6308c5..96bac81 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -5,7 +5,8 @@ * Admins can change any user's tier with one click. */ -import { useState, useEffect, useCallback } from 'react' +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' @@ -33,33 +34,26 @@ const TIER_COLORS: Record = { export function AdminPage() { const { session } = useAuth() - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) + const queryClient = useQueryClient() const [updating, setUpdating] = useState(null) - const [error, setError] = useState(null) const headers = { Authorization: `Bearer ${session?.access_token}` } - const fetchUsers = useCallback(async () => { - try { - setLoading(true) - setError(null) + 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) { - setError('Admin access required. Your email is not in the ADMIN_EMAILS list.') - return + 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') - const data = await resp.json() - setUsers(data.users || []) - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load users') - } finally { - setLoading(false) - } - }, [session?.access_token]) + return resp.json() + }, + enabled: !!session?.access_token, + }) - useEffect(() => { fetchUsers() }, [fetchUsers]) + 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) @@ -73,10 +67,8 @@ export function AdminPage() { const err = await resp.json().catch(() => ({})) throw new Error(err.detail || 'Failed to update tier') } - const result = await resp.json() - setUsers((prev) => - prev.map((u) => (u.id === userId ? { ...u, tier: result.tier } : u)), - ) + 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') @@ -90,7 +82,7 @@ export function AdminPage() {

{error}

- +
) } @@ -107,7 +99,7 @@ export function AdminPage() {

User management and tier control

- From 3b58adb0018bb7aacd1097711b9210bc94d25912 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 1 Mar 2026 16:42:03 -0500 Subject: [PATCH 3/6] fix: use UserTier enum in UpdateTierRequest + log Redis cache failures 1. UpdateTierRequest.tier typed as UserTier enum instead of str -- Pydantic validates automatically, OpenAPI shows allowed values, manual try/except validation removed (7 lines less) 2. Redis cache clear now logs warning on failure instead of silently passing -- aids debugging when cache isn't cleared Skipped: sync->async (Supabase client is sync, threadpool is correct), repo count aggregation (Supabase REST doesn't support GROUP BY). --- backend/routes/admin.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 185d462..a6dae1b 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -105,7 +105,7 @@ def list_users(auth: AuthContext = Depends(require_admin)) -> dict: # -- Tier management --------------------------------------------------------- class UpdateTierRequest(BaseModel): - tier: str # free, pro, enterprise + tier: UserTier @router.patch("/users/{user_id}/tier") @@ -115,14 +115,7 @@ def update_user_tier( auth: AuthContext = Depends(require_admin), ) -> dict: """Update a user's tier. Clears Redis cache so it takes effect immediately.""" - try: - new_tier = UserTier(request.tier) - except ValueError: - valid = [t.value for t in UserTier] - raise HTTPException( - status_code=400, - detail=f"Invalid tier '{request.tier}'. Must be one of: {valid}", - ) + new_tier = request.tier sb = get_supabase_service() @@ -140,8 +133,8 @@ def update_user_tier( if redis_client: try: redis_client.delete(f"user:tier:{user_id}") - except Exception: - pass + 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( From 5ac2caf1c7dbdb423d5ffccc4dee732c6260100a Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 1 Mar 2026 16:54:39 -0500 Subject: [PATCH 4/6] fix: validate tier values returned from DB against UserTier enum Unknown tier values in user_profiles or user_metadata now fall back to 'free' instead of passing through to the frontend unchecked. _VALID_TIERS set computed once at module level. --- backend/routes/admin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index a6dae1b..13ff8b8 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -15,6 +15,8 @@ router = APIRouter(prefix="/admin", tags=["Admin"]) +_VALID_TIERS = {t.value for t in UserTier} + ADMIN_EMAILS = set( e.strip() for e in os.getenv("ADMIN_EMAILS", "").split(",") @@ -87,7 +89,8 @@ def list_users(auth: AuthContext = Depends(require_admin)) -> dict: ) profile = profiles.get(uid, {}) - tier = profile.get("tier", meta.get("tier", "free")) + 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, From 9489dab48aaeac4debb6c01a4428e26aa39edf0f Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 1 Mar 2026 23:23:58 -0500 Subject: [PATCH 5/6] fix: case-insensitive admin email matching ADMIN_EMAILS stored lowercase, auth.email compared lowercase. Prevents rejection when Supabase returns 'Dev@Example.com' but env var has 'dev@example.com'. --- backend/routes/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 13ff8b8..bd9124e 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -18,7 +18,7 @@ _VALID_TIERS = {t.value for t in UserTier} ADMIN_EMAILS = set( - e.strip() + e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip() ) @@ -26,7 +26,7 @@ def require_admin(auth: AuthContext = Depends(require_auth)) -> AuthContext: """Dependency that ensures the caller is an admin.""" - if not auth.email or auth.email not in ADMIN_EMAILS: + if not auth.email or auth.email.lower() not in ADMIN_EMAILS: raise HTTPException(status_code=403, detail="Admin access required") return auth From 08ad4f3e1f6c0b5b8139ee039643cca4dc554c84 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 1 Mar 2026 23:38:34 -0500 Subject: [PATCH 6/6] fix: coalesce None user_metadata to empty dict user_metadata can be explicitly None (not just missing), which would crash meta.get('tier', 'free'). Added 'or {}' fallback. --- backend/routes/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index bd9124e..ee3df12 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -86,7 +86,7 @@ def list_users(auth: AuthContext = Depends(require_admin)) -> dict: 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"))