feat: Add JWT authentication to backend, configure frontend API client, fix streak layout

This commit is contained in:
2026-09-23 12:11:44 +05:30
parent ea03846b9c
commit a3e430cb67
27 changed files with 210 additions and 120 deletions

View File

@@ -72,6 +72,42 @@ app.add_middleware(
def health_check():
return {"status": "operational", "engine": "FastAPI Async", "environment": "local_dev"}
from fastapi.security import OAuth2PasswordBearer
import jwt
from typing import List, Dict, Any, Optional
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "super-secret-pragativeer-key")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id_str: str = payload.get("sub")
if user_id_str is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
try:
user_id = uuid.UUID(user_id_str)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject")
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
# --- ACCESS CONTROL DEPENDENCY ---
def get_effective_tier(user: models.User, session: Session) -> models.TierEnum:
@@ -204,7 +240,8 @@ def register(payload: RegisterPayload, session: Session = Depends(get_session)):
# Send welcome email in background (non-blocking)
send_welcome_email(payload.email, payload.name)
return {"success": True, "message": "User registered successfully", "trial_end": trial_end.isoformat() if trial_end else None}
access_token = create_access_token(data={"sub": str(new_user.id)})
return {"success": True, "message": "User registered successfully", "token": access_token, "user_id": str(new_user.id), "trial_end": trial_end.isoformat() if trial_end else None}
@app.post("/api/auth/login", tags=["Authentication"])
def login(payload: LoginPayload, session: Session = Depends(get_session)):
@@ -222,7 +259,8 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
(models.User.email == "demo@pragativeer.com") | (models.User.name == "Neha Verma")
)).first()
token_id = str(demo_user.id) if demo_user else NEHA_UUID
return {"success": True, "role": "user", "token": token_id}
access_token = create_access_token(data={"sub": token_id})
return {"success": True, "role": "user", "token": access_token, "user_id": token_id}
if payload.email == "admin@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@":
return {"success": True, "role": "admin", "token": "admin"}
@@ -236,20 +274,21 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
user.password_hash = bcrypt.hashpw(payload.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
session.add(user)
session.commit()
return {"success": True, "role": "admin" if (user.email and "admin" in user.email) else "user", "token": str(user.id)}
access_token = create_access_token(data={"sub": str(user.id)})
return {"success": True, "role": "admin" if (user.email and "admin" in user.email) else "user", "token": access_token, "user_id": str(user.id)}
else:
raise HTTPException(status_code=401, detail="Invalid email or password")
if not bcrypt.checkpw(payload.password.encode('utf-8'), user.password_hash.encode('utf-8')):
raise HTTPException(status_code=401, detail="Invalid email or password")
# Real application would issue a JWT here
return {"success": True, "role": "admin" if (user.email and "admin" in user.email) else "user", "token": str(user.id)}
access_token = create_access_token(data={"sub": str(user.id)})
return {"success": True, "role": "admin" if (user.email and "admin" in user.email) else "user", "token": access_token, "user_id": str(user.id)}
# --- CBT ENGINE ---
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session)):
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
query = select(models.Test).where(models.Test.is_active == True)
if exam_pattern:
query = query.where(models.Test.exam_pattern == exam_pattern)
@@ -260,14 +299,14 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] =
return session.exec(query).all()
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session)):
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
test = session.get(models.Test, test_id)
if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
return test
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"])
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session)):
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
test = session.get(models.Test, test_id)
if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
@@ -301,14 +340,14 @@ def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_sessi
return questions
@app.get("/api/questions/{question_id}", response_model=models.Question, tags=["CBT Engine"])
def get_question_by_id(question_id: UUID, session: Session = Depends(get_session)):
def get_question_by_id(question_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
question = session.get(models.Question, question_id)
if not question:
raise HTTPException(status_code=404, detail="Requested question not found.")
return question
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session)):
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
user_id = payload.get("user_id")
source_type_raw = payload.get("source_type", "Test")
source_id = payload.get("source_id")
@@ -442,7 +481,7 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_
# --- AI COACH SPACED REPETITION ENDPOINTS ---
@app.get("/api/mistakes/today", tags=["AI Coach"])
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
now = datetime.utcnow()
statement = select(models.Mistake, models.Question).join(
models.Question, models.Mistake.question_id == models.Question.id
@@ -458,7 +497,7 @@ def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)):
return response_data
@app.post("/api/mistakes/review", tags=["AI Coach"])
def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session)):
def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
mistake_id = payload.get("mistake_id")
selected_answer = payload.get("selected_answer")
@@ -505,14 +544,14 @@ def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(ge
# --- PARENT SUBSYSTEM ---
@app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"])
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)):
def register_parent(parent_data: models.Parent, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
session.add(parent_data)
session.commit()
session.refresh(parent_data)
return parent_data
@app.post("/api/parent/link-student", tags=["Parent Subsystem"])
def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session: Session = Depends(get_session)):
def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
parent = session.get(models.Parent, parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent account context not found.")
@@ -537,7 +576,7 @@ def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session
return {"message": "Student successfully linked to parent context.", "linked_student_id": student.id}
@app.get("/api/parent/{parent_id}/dashboard/{student_id}", tags=["Parent Subsystem"])
def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Session = Depends(get_session)):
def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
parent = session.get(models.Parent, parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.")
@@ -628,7 +667,7 @@ def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), c
return readiness_data
@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"])
def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session)):
def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
@@ -752,10 +791,11 @@ def switch_user_endpoint(id: str, session: Session = Depends(get_session)):
user = ensure_demo_user(uid, session) or session.get(models.User, uid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success", "user_id": str(user.id), "name": user.name}
access_token = create_access_token(data={"sub": str(user.id)})
return {"status": "success", "user_id": str(user.id), "name": user.name, "token": access_token}
@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"])
def get_user_profile(user_id: UUID, session: Session = Depends(get_session)):
def get_user_profile(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -797,7 +837,7 @@ class UserUpdate(BaseModel):
state: Optional[str] = None
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)):
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -817,7 +857,7 @@ class UserSessionUpdate(BaseModel):
session_token: str
@app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"])
def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session)):
def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -848,7 +888,7 @@ async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), sessio
return {"url": public_url}
@app.get("/api/users/{user_id}/attempts", tags=["Users"])
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
def get_user_attempts(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
query = select(models.Attempt).where(models.Attempt.user_id == user_id)
return session.exec(query).all()
@@ -860,7 +900,7 @@ DEMO_USER_UUIDS = {
}
@app.get("/api/users/{user_id}/stats", tags=["Users"])
def get_user_stats(user_id: UUID, session: Session = Depends(get_session)):
def get_user_stats(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
"""Return real-time performance stats for the user from the database."""
user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
if not user:
@@ -928,7 +968,7 @@ def get_user_stats(user_id: UUID, session: Session = Depends(get_session)):
def get_leaderboard(
tab: str = "all-india",
user_id: Optional[UUID] = None,
session: Session = Depends(get_session)
session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)
):
"""Return leaderboard rankings calculated from real users and attempts in DB."""
# Top benchmark peer students
@@ -1013,7 +1053,7 @@ def get_leaderboard(
# --- ADMIN ROUTES ---
@app.get("/api/admin/settings", tags=["Admin"])
def get_system_settings(session: Session = Depends(get_session)):
def get_system_settings(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
settings = session.get(models.SystemSettings, "global")
if not settings:
settings = models.SystemSettings()
@@ -1030,7 +1070,7 @@ class SettingsUpdate(BaseModel):
trial_tier: Optional[models.TierEnum] = None
@app.put("/api/admin/settings", tags=["Admin"])
def update_system_settings(payload: SettingsUpdate, session: Session = Depends(get_session)):
def update_system_settings(payload: SettingsUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
settings = session.get(models.SystemSettings, "global")
if not settings:
settings = models.SystemSettings()
@@ -1048,7 +1088,7 @@ def update_system_settings(payload: SettingsUpdate, session: Session = Depends(g
return settings
@app.get("/api/admin/tests", tags=["Admin"])
def get_admin_tests(session: Session = Depends(get_session)):
def get_admin_tests(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
tests = session.exec(select(models.Test)).all()
return tests
@@ -1133,7 +1173,7 @@ async def upload_admin_test(
@app.post("/api/admin/tests/create_manual", tags=["Admin"])
def create_manual_test(
data: ManualTestCreate,
session: Session = Depends(get_session)
session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)
):
import uuid
# 1. Create the test object
@@ -1187,7 +1227,7 @@ def create_manual_test(
return {"message": "Test created successfully", "test_id": test_obj.id}
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"])
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)):
def toggle_test_status(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
test_obj = session.get(models.Test, test_id)
if not test_obj:
raise HTTPException(status_code=404, detail="Test not found")
@@ -1199,7 +1239,7 @@ def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)):
return {"status": "success", "is_active": test_obj.is_active}
@app.delete("/api/admin/tests/{test_id}", tags=["Admin"])
def delete_test(test_id: UUID, session: Session = Depends(get_session)):
def delete_test(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
from sqlmodel import select
test_obj = session.get(models.Test, test_id)
if not test_obj:
@@ -1280,12 +1320,12 @@ class CouponUpdate(BaseModel):
expires_at: Optional[datetime] = None
@app.get("/api/admin/coupons", tags=["Admin"])
def get_admin_coupons(session: Session = Depends(get_session)):
def get_admin_coupons(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
coupons = session.exec(select(models.Coupon)).all()
return coupons
@app.post("/api/admin/coupons", tags=["Admin"])
def create_coupon(payload: CouponCreate, session: Session = Depends(get_session)):
def create_coupon(payload: CouponCreate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
coupon = models.Coupon(**payload.dict())
session.add(coupon)
session.commit()
@@ -1293,7 +1333,7 @@ def create_coupon(payload: CouponCreate, session: Session = Depends(get_session)
return coupon
@app.put("/api/admin/coupons/{coupon_id}", tags=["Admin"])
def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Depends(get_session)):
def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
coupon = session.get(models.Coupon, coupon_id)
if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
@@ -1308,7 +1348,7 @@ def update_coupon(coupon_id: UUID, payload: CouponUpdate, session: Session = Dep
return coupon
@app.delete("/api/admin/coupons/{coupon_id}", tags=["Admin"])
def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session)):
def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
coupon = session.get(models.Coupon, coupon_id)
if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
@@ -1355,7 +1395,7 @@ class AdminUserPatch(BaseModel):
state: Optional[str] = None
@app.get("/api/admin/students", tags=["Admin"])
def list_all_students(session: Session = Depends(get_session)):
def list_all_students(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
"""Return all users with basic stats for the admin student directory."""
users = session.exec(select(models.User)).all()
result = []
@@ -1393,7 +1433,7 @@ def list_all_students(session: Session = Depends(get_session)):
@app.get("/api/admin/students/{user_id}", tags=["Admin"])
def get_student_detail(user_id: UUID, session: Session = Depends(get_session)):
def get_student_detail(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
"""Full student profile for admin detail view."""
user = session.get(models.User, user_id)
if not user:
@@ -1490,7 +1530,7 @@ def get_student_detail(user_id: UUID, session: Session = Depends(get_session)):
@app.patch("/api/admin/students/{user_id}", tags=["Admin"])
def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session)):
def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
"""Admin update: tier, name, streak, exam, location."""
user = session.get(models.User, user_id)
if not user: