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

@@ -87,7 +87,7 @@ All fundamental colors are defined as CSS variables in `src/app/globals.css` and
| **Cyan (`--cyan`)** | `rgb(12, 96, 96)` (Deep AI Teal) | `#00F0FF` (Cyber Cyan) | `var(--cyan)`, `text-[var(--cyan-ai)]`, `bg-[var(--cyan-ai-10)]` | `var(--cyan)`, `text-[#00F0FF]`, `bg-[#00F0FF]/10` | AI Coach, Tutor Chat, PrepPilot AI | | **Cyan (`--cyan`)** | `rgb(12, 96, 96)` (Deep AI Teal) | `#00F0FF` (Cyber Cyan) | `var(--cyan)`, `text-[var(--cyan-ai)]`, `bg-[var(--cyan-ai-10)]` | `var(--cyan)`, `text-[#00F0FF]`, `bg-[#00F0FF]/10` | AI Coach, Tutor Chat, PrepPilot AI |
| **Light Cyan (`--lightcyan`)** | `#E0FFFF` (Unchanged) | `#E0FFFF` (Unchanged) | W3C Standard `lightcyan` | W3C Standard `lightcyan` | Standard CSS compatibility | | **Light Cyan (`--lightcyan`)** | `#E0FFFF` (Unchanged) | `#E0FFFF` (Unchanged) | W3C Standard `lightcyan` | W3C Standard `lightcyan` | Standard CSS compatibility |
| **Dark Cyan (`--darkcyan`)** | `#008B8B` (Unchanged) | `#008B8B` (Unchanged) | W3C Standard `darkcyan` | W3C Standard `darkcyan` | Standard CSS compatibility | | **Dark Cyan (`--darkcyan`)** | `#008B8B` (Unchanged) | `#008B8B` (Unchanged) | W3C Standard `darkcyan` | W3C Standard `darkcyan` | Standard CSS compatibility |
| **Neon Lime** | Subtle green pill badges | `#D1FF4D` | Subtle green pill badges | `bg-primary`, `text-primary`, `shadow-[0_0_15px_-5px_#D1FF4D]` | Dark mode hero highlights & primary actions | | **Neon Lime** | Subtle green pill badges | `#D1FF4D` | Subtle green pill badges | `bg-primary`, `text-primary`, `shadow-1[0_0_15px_-5px_#D1FF4D]` | Dark mode hero highlights & primary actions |
| **Amber Gold** | `#EAB308` | `#EAB308` | `text-yellow-700`, `bg-yellow-50`, `border-yellow-300/60` | `text-yellow-400`, `bg-yellow-500/10`, `shadow-[0_0_12px_-4px_rgba(234,179,8,0.2)]` | Pro Tier, CBT Marked for Review questions | | **Amber Gold** | `#EAB308` | `#EAB308` | `text-yellow-700`, `bg-yellow-50`, `border-yellow-300/60` | `text-yellow-400`, `bg-yellow-500/10`, `shadow-[0_0_12px_-4px_rgba(234,179,8,0.2)]` | Pro Tier, CBT Marked for Review questions |
| **Emerald Green** | `#22C55E` | `#22C55E` | `bg-green-500 text-white` | `bg-green-500 text-white shadow-sm` | CBT Answered questions, Success toasts | | **Emerald Green** | `#22C55E` | `#22C55E` | `bg-green-500 text-white` | `bg-green-500 text-white shadow-sm` | CBT Answered questions, Success toasts |
| **Crimson Red** | `#EF4444` | `#EF4444` | `bg-red-500 text-white`, `text-red-600` | `bg-red-500 text-white`, `text-red-400` | CBT Not Answered, Error banners, Destructive | | **Crimson Red** | `#EF4444` | `#EF4444` | `bg-red-500 text-white`, `text-red-600` | `bg-red-500 text-white`, `text-red-400` | CBT Not Answered, Error banners, Destructive |

View File

@@ -72,6 +72,42 @@ app.add_middleware(
def health_check(): def health_check():
return {"status": "operational", "engine": "FastAPI Async", "environment": "local_dev"} 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 --- # --- ACCESS CONTROL DEPENDENCY ---
def get_effective_tier(user: models.User, session: Session) -> models.TierEnum: 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 in background (non-blocking)
send_welcome_email(payload.email, payload.name) 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"]) @app.post("/api/auth/login", tags=["Authentication"])
def login(payload: LoginPayload, session: Session = Depends(get_session)): 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") (models.User.email == "demo@pragativeer.com") | (models.User.name == "Neha Verma")
)).first() )).first()
token_id = str(demo_user.id) if demo_user else NEHA_UUID 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@": if payload.email == "admin@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@":
return {"success": True, "role": "admin", "token": "admin"} 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') user.password_hash = bcrypt.hashpw(payload.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
session.add(user) session.add(user)
session.commit() 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: else:
raise HTTPException(status_code=401, detail="Invalid email or password") 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')): 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") raise HTTPException(status_code=401, detail="Invalid email or password")
# Real application would issue a JWT here 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": 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 --- # --- CBT ENGINE ---
@app.get("/api/tests", response_model=List[models.Test], tags=["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) query = select(models.Test).where(models.Test.is_active == True)
if exam_pattern: if exam_pattern:
query = query.where(models.Test.exam_pattern == 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() return session.exec(query).all()
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"]) @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) test = session.get(models.Test, test_id)
if not test: if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.") raise HTTPException(status_code=404, detail="Requested examination structure not found.")
return test return test
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"]) @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) test = session.get(models.Test, test_id)
if not test: if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.") 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 return questions
@app.get("/api/questions/{question_id}", response_model=models.Question, tags=["CBT Engine"]) @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) question = session.get(models.Question, question_id)
if not question: if not question:
raise HTTPException(status_code=404, detail="Requested question not found.") raise HTTPException(status_code=404, detail="Requested question not found.")
return question return question
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"]) @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") user_id = payload.get("user_id")
source_type_raw = payload.get("source_type", "Test") source_type_raw = payload.get("source_type", "Test")
source_id = payload.get("source_id") 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 --- # --- AI COACH SPACED REPETITION ENDPOINTS ---
@app.get("/api/mistakes/today", tags=["AI Coach"]) @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() now = datetime.utcnow()
statement = select(models.Mistake, models.Question).join( statement = select(models.Mistake, models.Question).join(
models.Question, models.Mistake.question_id == models.Question.id 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 return response_data
@app.post("/api/mistakes/review", tags=["AI Coach"]) @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") mistake_id = payload.get("mistake_id")
selected_answer = payload.get("selected_answer") selected_answer = payload.get("selected_answer")
@@ -505,14 +544,14 @@ def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(ge
# --- PARENT SUBSYSTEM --- # --- PARENT SUBSYSTEM ---
@app.post("/api/parent/register", response_model=models.Parent, tags=["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.add(parent_data)
session.commit() session.commit()
session.refresh(parent_data) session.refresh(parent_data)
return parent_data return parent_data
@app.post("/api/parent/link-student", tags=["Parent Subsystem"]) @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) parent = session.get(models.Parent, parent_id)
if not parent: if not parent:
raise HTTPException(status_code=404, detail="Parent account context not found.") 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} 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"]) @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) parent = session.get(models.Parent, parent_id)
if not parent: if not parent:
raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.") 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 return readiness_data
@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"]) @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) user = session.get(models.User, user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found.") 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) user = ensure_demo_user(uid, session) or session.get(models.User, uid)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") 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"]) @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) user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -797,7 +837,7 @@ class UserUpdate(BaseModel):
state: Optional[str] = None state: Optional[str] = None
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"]) @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) user = session.get(models.User, user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -817,7 +857,7 @@ class UserSessionUpdate(BaseModel):
session_token: str session_token: str
@app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"]) @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) user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") 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} return {"url": public_url}
@app.get("/api/users/{user_id}/attempts", tags=["Users"]) @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) query = select(models.Attempt).where(models.Attempt.user_id == user_id)
return session.exec(query).all() return session.exec(query).all()
@@ -860,7 +900,7 @@ DEMO_USER_UUIDS = {
} }
@app.get("/api/users/{user_id}/stats", tags=["Users"]) @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.""" """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) user = ensure_demo_user(user_id, session) or session.get(models.User, user_id)
if not user: if not user:
@@ -928,7 +968,7 @@ def get_user_stats(user_id: UUID, session: Session = Depends(get_session)):
def get_leaderboard( def get_leaderboard(
tab: str = "all-india", tab: str = "all-india",
user_id: Optional[UUID] = None, 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.""" """Return leaderboard rankings calculated from real users and attempts in DB."""
# Top benchmark peer students # Top benchmark peer students
@@ -1013,7 +1053,7 @@ def get_leaderboard(
# --- ADMIN ROUTES --- # --- ADMIN ROUTES ---
@app.get("/api/admin/settings", tags=["Admin"]) @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") settings = session.get(models.SystemSettings, "global")
if not settings: if not settings:
settings = models.SystemSettings() settings = models.SystemSettings()
@@ -1030,7 +1070,7 @@ class SettingsUpdate(BaseModel):
trial_tier: Optional[models.TierEnum] = None trial_tier: Optional[models.TierEnum] = None
@app.put("/api/admin/settings", tags=["Admin"]) @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") settings = session.get(models.SystemSettings, "global")
if not settings: if not settings:
settings = models.SystemSettings() settings = models.SystemSettings()
@@ -1048,7 +1088,7 @@ def update_system_settings(payload: SettingsUpdate, session: Session = Depends(g
return settings return settings
@app.get("/api/admin/tests", tags=["Admin"]) @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() tests = session.exec(select(models.Test)).all()
return tests return tests
@@ -1133,7 +1173,7 @@ async def upload_admin_test(
@app.post("/api/admin/tests/create_manual", tags=["Admin"]) @app.post("/api/admin/tests/create_manual", tags=["Admin"])
def create_manual_test( def create_manual_test(
data: ManualTestCreate, data: ManualTestCreate,
session: Session = Depends(get_session) session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)
): ):
import uuid import uuid
# 1. Create the test object # 1. Create the test object
@@ -1187,7 +1227,7 @@ def create_manual_test(
return {"message": "Test created successfully", "test_id": test_obj.id} return {"message": "Test created successfully", "test_id": test_obj.id}
@app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"]) @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) test_obj = session.get(models.Test, test_id)
if not test_obj: if not test_obj:
raise HTTPException(status_code=404, detail="Test not found") 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} return {"status": "success", "is_active": test_obj.is_active}
@app.delete("/api/admin/tests/{test_id}", tags=["Admin"]) @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 from sqlmodel import select
test_obj = session.get(models.Test, test_id) test_obj = session.get(models.Test, test_id)
if not test_obj: if not test_obj:
@@ -1280,12 +1320,12 @@ class CouponUpdate(BaseModel):
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
@app.get("/api/admin/coupons", tags=["Admin"]) @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() coupons = session.exec(select(models.Coupon)).all()
return coupons return coupons
@app.post("/api/admin/coupons", tags=["Admin"]) @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()) coupon = models.Coupon(**payload.dict())
session.add(coupon) session.add(coupon)
session.commit() session.commit()
@@ -1293,7 +1333,7 @@ def create_coupon(payload: CouponCreate, session: Session = Depends(get_session)
return coupon return coupon
@app.put("/api/admin/coupons/{coupon_id}", tags=["Admin"]) @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) coupon = session.get(models.Coupon, coupon_id)
if not coupon: if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found") 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 return coupon
@app.delete("/api/admin/coupons/{coupon_id}", tags=["Admin"]) @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) coupon = session.get(models.Coupon, coupon_id)
if not coupon: if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found") raise HTTPException(status_code=404, detail="Coupon not found")
@@ -1355,7 +1395,7 @@ class AdminUserPatch(BaseModel):
state: Optional[str] = None state: Optional[str] = None
@app.get("/api/admin/students", tags=["Admin"]) @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.""" """Return all users with basic stats for the admin student directory."""
users = session.exec(select(models.User)).all() users = session.exec(select(models.User)).all()
result = [] result = []
@@ -1393,7 +1433,7 @@ def list_all_students(session: Session = Depends(get_session)):
@app.get("/api/admin/students/{user_id}", tags=["Admin"]) @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.""" """Full student profile for admin detail view."""
user = session.get(models.User, user_id) user = session.get(models.User, user_id)
if not user: 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"]) @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.""" """Admin update: tier, name, streak, exam, location."""
user = session.get(models.User, user_id) user = session.get(models.User, user_id)
if not user: if not user:

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -25,7 +26,7 @@ export default function CouponsAdmin() {
const fetchCoupons = async () => { const fetchCoupons = async () => {
try { try {
const res = await fetch('/api/admin/coupons') const res = await fetchWithAuth('/api/admin/coupons')
const data = await res.json() const data = await res.json()
setCoupons(data) setCoupons(data)
} catch (e) { } catch (e) {
@@ -39,7 +40,7 @@ export default function CouponsAdmin() {
if (!newCoupon.code) return if (!newCoupon.code) return
setSubmitting(true) setSubmitting(true)
try { try {
await fetch('/api/admin/coupons', { await fetchWithAuth('/api/admin/coupons', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(newCoupon) body: JSON.stringify(newCoupon)
@@ -56,7 +57,7 @@ export default function CouponsAdmin() {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Delete this coupon?")) return if (!confirm("Delete this coupon?")) return
try { try {
await fetch(`/api/admin/coupons/${id}`, { method: "DELETE" }) await fetchWithAuth(`/api/admin/coupons/${id}`, { method: "DELETE" })
await fetchCoupons() await fetchCoupons()
} catch (e) { } catch (e) {
console.error(e) console.error(e)

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { fetchWithAuth } from "@/lib/apiClient";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -15,7 +16,7 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetch("/api/admin/settings") fetchWithAuth("/api/admin/settings")
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if(data && !data.detail) { if(data && !data.detail) {
@@ -32,7 +33,7 @@ export default function SettingsPage() {
const saveSettings = async (section: string, overrides: Partial<typeof settings> = {}) => { const saveSettings = async (section: string, overrides: Partial<typeof settings> = {}) => {
try { try {
const payload = { ...settings, ...overrides }; const payload = { ...settings, ...overrides };
const res = await fetch("/api/admin/settings", { const res = await fetchWithAuth("/api/admin/settings", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -46,7 +47,7 @@ export default function StudentKundliPage() {
const [editExam, setEditExam] = useState("") const [editExam, setEditExam] = useState("")
useEffect(() => { useEffect(() => {
fetch(`/api/admin/students/${studentId}`) fetchWithAuth(`/api/admin/students/${studentId}`)
.then(r => { if (!r.ok) throw new Error("Not found"); return r.json() }) .then(r => { if (!r.ok) throw new Error("Not found"); return r.json() })
.then(d => { .then(d => {
setData(d) setData(d)
@@ -62,7 +63,7 @@ export default function StudentKundliPage() {
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
try { try {
const res = await fetch(`/api/admin/students/${studentId}`, { const res = await fetchWithAuth(`/api/admin/students/${studentId}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -74,7 +75,7 @@ export default function StudentKundliPage() {
}) })
if (!res.ok) throw new Error("Failed to save") if (!res.ok) throw new Error("Failed to save")
// Refresh data // Refresh data
const fresh = await fetch(`/api/admin/students/${studentId}`).then(r => r.json()) const fresh = await fetchWithAuth(`/api/admin/students/${studentId}`).then(r => r.json())
setData(fresh) setData(fresh)
setEditing(false) setEditing(false)
} catch (e: any) { } catch (e: any) {

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -25,7 +26,7 @@ export default function StudentsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
useEffect(() => { useEffect(() => {
fetch("/api/admin/students") fetchWithAuth("/api/admin/students")
.then(r => r.json()) .then(r => r.json())
.then(data => setStudents(data)) .then(data => setStudents(data))
.catch(console.error) .catch(console.error)

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -55,7 +56,7 @@ export default function AdminTestsPage() {
const fetchTests = async () => { const fetchTests = async () => {
try { try {
const res = await fetch("/api/admin/tests") const res = await fetchWithAuth("/api/admin/tests")
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setTests(data) setTests(data)
@@ -74,7 +75,7 @@ export default function AdminTestsPage() {
const handleToggle = async (testId: string) => { const handleToggle = async (testId: string) => {
try { try {
const res = await fetch(`/api/admin/tests/${testId}/toggle`, { const res = await fetchWithAuth(`/api/admin/tests/${testId}/toggle`, {
method: "PUT" method: "PUT"
}) })
if (res.ok) { if (res.ok) {
@@ -88,7 +89,7 @@ export default function AdminTestsPage() {
const handleDelete = async (testId: string) => { const handleDelete = async (testId: string) => {
if (!confirm("Are you sure you want to delete this test and all its questions? This action cannot be undone.")) return if (!confirm("Are you sure you want to delete this test and all its questions? This action cannot be undone.")) return
try { try {
const res = await fetch(`/api/admin/tests/${testId}`, { const res = await fetchWithAuth(`/api/admin/tests/${testId}`, {
method: "DELETE" method: "DELETE"
}) })
if (res.ok) { if (res.ok) {
@@ -191,7 +192,7 @@ export default function AdminTestsPage() {
}; };
try { try {
const res = await fetch("/api/admin/tests/create_manual", { const res = await fetchWithAuth("/api/admin/tests/create_manual", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)

View File

@@ -121,20 +121,20 @@ export async function GET(req: NextRequest) {
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 44, fontWeight: 900, color: textColor, letterSpacing: '-0.02em' }}>{name}</div> <div style={{ display: 'flex', fontSize: 44, fontWeight: 900, color: textColor, letterSpacing: '-0.02em' }}>{name}</div>
<div style={{ fontSize: 20, color: textSec, marginTop: 4 }}>Generated: {exportDate}</div> <div style={{ display: 'flex', fontSize: 20, color: textSec, marginTop: 4 }}>{`Generated: ${exportDate}`}</div>
<div style={{ display: 'flex', marginTop: 8 }}> <div style={{ display: 'flex', marginTop: 8 }}>
<div style={{ fontSize: 22, color: '#06b6d4', fontWeight: 700, marginRight: 16 }}>{`${target} Aspirant`}</div> <div style={{ display: 'flex', fontSize: 22, color: '#06b6d4', fontWeight: 700, marginRight: 16 }}>{`${target} Aspirant`}</div>
<div style={{ fontSize: 22, color: textSec, fontWeight: 600 }}>•</div> <div style={{ display: 'flex', fontSize: 22, color: textSec, fontWeight: 600 }}>•</div>
<div style={{ fontSize: 22, color: textTert, fontWeight: 600, marginLeft: 16 }}>{`${plan} Plan`}</div> <div style={{ display: 'flex', fontSize: 22, color: textTert, fontWeight: 600, marginLeft: 16 }}>{`${plan} Plan`}</div>
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
<div style={{ fontSize: 22, color: textTert, fontWeight: 600 }}>pragativeer.com</div> <div style={{ display: 'flex', fontSize: 22, color: textTert, fontWeight: 600 }}>pragativeer.com</div>
<div style={{ fontSize: 22, color: textSec, fontWeight: 600 }}>contact@pragativeer.com</div> <div style={{ display: 'flex', fontSize: 22, color: textSec, fontWeight: 600 }}>contact@pragativeer.com</div>
<div style={{ fontSize: 22, color: textSec, fontWeight: 600 }}>+91 9079337961</div> <div style={{ display: 'flex', fontSize: 22, color: textSec, fontWeight: 600 }}>+91 9079337961</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2 } from "lucide-react" import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2 } from "lucide-react"
@@ -25,14 +26,14 @@ export default function AIDailyPracticePage() {
try { try {
const pattern = user.target_exam || "JEE_Main"; const pattern = user.target_exam || "JEE_Main";
// Also fetch from the correct Next.js proxied route instead of localhost:8000 // Also fetch from the correct Next.js proxied route instead of localhost:8000
const res = await fetch(`/api/tests?type=DPS&exam_pattern=${pattern}`); const res = await fetchWithAuth(`/api/tests?type=DPS&exam_pattern=${pattern}`);
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setDpps(data) setDpps(data)
} }
if (userId) { if (userId) {
const attemptsRes = await fetch(`/api/users/${userId}/attempts`); const attemptsRes = await fetchWithAuth(`/api/users/${userId}/attempts`);
if (attemptsRes.ok) { if (attemptsRes.ok) {
const attemptsData = await attemptsRes.json(); const attemptsData = await attemptsRes.json();
setAttempts(attemptsData); setAttempts(attemptsData);

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap" import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
import { StudyPlanCard } from "@/components/AI/StudyPlanCard" import { StudyPlanCard } from "@/components/AI/StudyPlanCard"
@@ -39,7 +40,7 @@ export default function AICoachDashboard() {
useEffect(() => { useEffect(() => {
if (!userLoading && userId) { if (!userLoading && userId) {
fetch(`/api/users/${userId}/stats`) fetchWithAuth(`/api/users/${userId}/stats`)
.then(res => res.ok ? res.json() : null) .then(res => res.ok ? res.json() : null)
.then(data => setStats(data)) .then(data => setStats(data))
.catch(() => setStats(null)) .catch(() => setStats(null))

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -37,7 +38,7 @@ export default function CBTPage() {
async function loadUser() { async function loadUser() {
try { try {
const userRes = await fetch(`/api/users/${userId}`); const userRes = await fetchWithAuth(`/api/users/${userId}`);
if (!userRes.ok) throw new Error("Failed to load user target exam"); if (!userRes.ok) throw new Error("Failed to load user target exam");
const userData = await userRes.json(); const userData = await userRes.json();
const pattern = userData.target_exam; const pattern = userData.target_exam;
@@ -75,7 +76,7 @@ export default function CBTPage() {
setExams(testsData); setExams(testsData);
} }
const attemptsRes = await fetch(`/api/users/${userId}/attempts`); const attemptsRes = await fetchWithAuth(`/api/users/${userId}/attempts`);
if (attemptsRes.ok) { if (attemptsRes.ok) {
const attemptsData = await attemptsRes.json(); const attemptsData = await attemptsRes.json();
setAttempts(attemptsData); setAttempts(attemptsData);

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2 } from "lucide-react" import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2 } from "lucide-react"
@@ -25,14 +26,14 @@ export default function DPPPage() {
try { try {
const pattern = user.target_exam || "JEE_Main"; const pattern = user.target_exam || "JEE_Main";
// Also fetch from the correct Next.js proxied route instead of localhost:8000 // Also fetch from the correct Next.js proxied route instead of localhost:8000
const res = await fetch(`/api/tests?type=DPS&exam_pattern=${pattern}`); const res = await fetchWithAuth(`/api/tests?type=DPS&exam_pattern=${pattern}`);
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setDpps(data) setDpps(data)
} }
if (userId) { if (userId) {
const attemptsRes = await fetch(`/api/users/${userId}/attempts`); const attemptsRes = await fetchWithAuth(`/api/users/${userId}/attempts`);
if (attemptsRes.ok) { if (attemptsRes.ok) {
const attemptsData = await attemptsRes.json(); const attemptsData = await attemptsRes.json();
setAttempts(attemptsData); setAttempts(attemptsData);

View File

@@ -335,7 +335,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
<button <button
type="button" type="button"
onClick={() => window.dispatchEvent(new CustomEvent("pragativeer-show-streak-animation"))} onClick={() => window.dispatchEvent(new CustomEvent("pragativeer-show-streak-animation"))}
className="hover:opacity-85 transition-opacity cursor-pointer border-none bg-transparent p-0" className="flex items-center justify-center hover:opacity-85 transition-opacity cursor-pointer border-none bg-transparent p-0"
title="View Streak Celebration" title="View Streak Celebration"
> >
<StreakCounter streak={user.streak_count} /> <StreakCounter streak={user.streak_count} />

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useRef, useState, useEffect } from "react" import { useRef, useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -26,7 +27,7 @@ export default function DashboardPage() {
} }
async function fetchStats() { async function fetchStats() {
try { try {
const res = await fetch(`/api/users/${currentUserId}/stats`) const res = await fetchWithAuth(`/api/users/${currentUserId}/stats`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setUserStats(data) setUserStats(data)

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -50,7 +51,7 @@ export default function SettingsPage() {
formData.append("file", file); formData.append("file", file);
try { try {
const res = await fetch(`/api/users/${userId}/avatar`, { const res = await fetchWithAuth(`/api/users/${userId}/avatar`, {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
@@ -72,7 +73,7 @@ export default function SettingsPage() {
if (!userId) return; if (!userId) return;
setSaving(true); setSaving(true);
try { try {
const res = await fetch(`/api/users/${userId}`, { const res = await fetchWithAuth(`/api/users/${userId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Flame, Gift, Star, Trophy, Target, ArrowRight, CheckCircle2 } from "lucide-react" import { Flame, Gift, Star, Trophy, Target, ArrowRight, CheckCircle2 } from "lucide-react"
@@ -20,7 +21,7 @@ export default function StreaksPage() {
} }
async function loadStats() { async function loadStats() {
try { try {
const res = await fetch(`/api/users/${user?.id}/stats`) const res = await fetchWithAuth(`/api/users/${user?.id}/stats`)
if (res.ok) { if (res.ok) {
const stats = await res.json() const stats = await res.json()
if (!stats.is_new_user) { if (!stats.is_new_user) {

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Eye, EyeOff } from "lucide-react" import { ArrowLeft, Eye, EyeOff } from "lucide-react"
@@ -34,14 +35,14 @@ export default function LoginPage() {
const cookieVal = data.role === "admin" ? "admin" : (data.token || "authenticated"); const cookieVal = data.role === "admin" ? "admin" : (data.token || "authenticated");
document.cookie = `auth_token=${cookieVal}; path=/; max-age=2592000; SameSite=Lax`; document.cookie = `auth_token=${cookieVal}; path=/; max-age=2592000; SameSite=Lax`;
localStorage.setItem("activeUserId", data.token); localStorage.setItem("activeUserId", data.user_id || data.token);
// Create new session token for single-device login // Store the JWT as the active session token
const sessionToken = crypto.randomUUID(); const sessionToken = data.token;
localStorage.setItem("active_session_token", sessionToken); localStorage.setItem("active_session_token", sessionToken);
if (data.token && data.token !== "admin") { if (data.token && data.token !== "admin") {
try { try {
await fetch(`/api/users/${data.token}/session`, { await fetchWithAuth(`/api/users/${data.user_id}/session`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken }) body: JSON.stringify({ session_token: sessionToken })

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Eye, EyeOff, CheckCircle2 } from "lucide-react" import { ArrowLeft, Eye, EyeOff, CheckCircle2 } from "lucide-react"
@@ -61,10 +62,10 @@ export default function SignupPage() {
if (loginData.success && typeof window !== "undefined") { if (loginData.success && typeof window !== "undefined") {
document.cookie = `auth_token=${loginData.token}; path=/; max-age=2592000; SameSite=Lax`; document.cookie = `auth_token=${loginData.token}; path=/; max-age=2592000; SameSite=Lax`;
localStorage.setItem("activeUserId", loginData.token); localStorage.setItem("activeUserId", loginData.user_id || loginData.token);
const sessionToken = crypto.randomUUID(); const sessionToken = loginData.token;
localStorage.setItem("active_session_token", sessionToken); localStorage.setItem("active_session_token", sessionToken);
await fetch(`/api/users/${loginData.token}/session`, { await fetchWithAuth(`/api/users/${loginData.user_id}/session`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken }) body: JSON.stringify({ session_token: sessionToken })

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -66,7 +67,7 @@ export function AITutorChat() {
try { try {
const activeId = user?.id || (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID; const activeId = user?.id || (typeof window !== "undefined" ? localStorage.getItem("activeUserId") : null) || DEFAULT_USER_ID;
const res = await fetch('/api/ai-coach/chat/stream', { const res = await fetchWithAuth('/api/ai-coach/chat/stream', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: activeId, message: userText }) body: JSON.stringify({ user_id: activeId, message: userText })

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { RefreshCw, TrendingUp, MapPin, Star } from "lucide-react" import { RefreshCw, TrendingUp, MapPin, Star } from "lucide-react"
@@ -61,7 +62,7 @@ export function CollegePredictorCard() {
if (userLoading || !userId) return if (userLoading || !userId) return
async function fetchData() { async function fetchData() {
try { try {
const res = await fetch(`/api/ai-coach/readiness/${userId}`) const res = await fetchWithAuth(`/api/ai-coach/readiness/${userId}`)
if (!res.ok) throw new Error("fallback") if (!res.ok) throw new Error("fallback")
} catch { } catch {
// Use mock data — no-op // Use mock data — no-op

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { TrendingUp, Flame, RefreshCw } from "lucide-react" import { TrendingUp, Flame, RefreshCw } from "lucide-react"
@@ -18,7 +19,7 @@ export function ReadinessGauge() {
async function fetchReadiness() { async function fetchReadiness() {
try { try {
const res = await fetch(`/api/ai-coach/readiness/${userId}`) const res = await fetchWithAuth(`/api/ai-coach/readiness/${userId}`)
if (!res.ok) throw new Error("Failed to fetch") if (!res.ok) throw new Error("Failed to fetch")
const json = await res.json() const json = await res.json()
setData(json) setData(json)

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -16,7 +17,7 @@ export function ReferralCard() {
if (userLoading || !userId) return; if (userLoading || !userId) return;
async function fetchReferral() { async function fetchReferral() {
try { try {
const res = await fetch(`/api/growth/referral/${userId}`) const res = await fetchWithAuth(`/api/growth/referral/${userId}`)
if (res.ok) { if (res.ok) {
const json = await res.json() const json = await res.json()
setData(json) setData(json)

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Check, RefreshCw } from "lucide-react" import { Check, RefreshCw } from "lucide-react"
@@ -17,7 +18,7 @@ export function StudyPlanCard() {
async function fetchPlan() { async function fetchPlan() {
try { try {
const res = await fetch(`/api/ai-coach/study-plan/today?user_id=${userId}`) const res = await fetchWithAuth(`/api/ai-coach/study-plan/today?user_id=${userId}`)
if (!res.ok) { if (!res.ok) {
// If no plan is generated yet, fallback silently // If no plan is generated yet, fallback silently
throw new Error("silent_fallback") throw new Error("silent_fallback")

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient";
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Send, Loader2, BrainCircuit, Sparkles } from "lucide-react" import { Send, Loader2, BrainCircuit, Sparkles } from "lucide-react"
@@ -49,7 +50,7 @@ export function TutorChat() {
setStreaming(true) setStreaming(true)
try { try {
const res = await fetch("/api/ai-coach/chat/stream", { const res = await fetchWithAuth("/api/ai-coach/chat/stream", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, message: text.trim() }) body: JSON.stringify({ user_id: userId, message: text.trim() })

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { fetchWithAuth } from "@/lib/apiClient";
export interface UserProfile { export interface UserProfile {
id: string; id: string;
@@ -110,7 +111,7 @@ async function fetchSettings(): Promise<any> {
if (cachedSettings && now < settingsCacheExpiry) return cachedSettings; if (cachedSettings && now < settingsCacheExpiry) return cachedSettings;
try { try {
const res = await fetch(`/api/admin/settings`, { cache: "no-store" }); const res = await fetchWithAuth(`/api/admin/settings`, { cache: "no-store" });
if (res.ok) { if (res.ok) {
cachedSettings = await res.json(); cachedSettings = await res.json();
settingsCacheExpiry = now + 60_000; // cache for 60 seconds settingsCacheExpiry = now + 60_000; // cache for 60 seconds
@@ -147,7 +148,7 @@ export function useActiveUser() {
try { try {
// ── PERF FIX: run both fetches in PARALLEL instead of sequentially ── // ── PERF FIX: run both fetches in PARALLEL instead of sequentially ──
const [res, settingsData] = await Promise.all([ const [res, settingsData] = await Promise.all([
fetch(`/api/users/${id}`), fetchWithAuth(`/api/users/${id}`),
fetchSettings(), fetchSettings(),
]); ]);
@@ -227,47 +228,48 @@ export function useActiveUser() {
setSwitching(true); setSwitching(true);
// ── Update cookie + localStorage immediately (optimistic) ───────────────
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken);
localStorage.setItem("activeUserId", newId);
// Update refs and state before awaiting network
activeUserIdRef.current = newId;
setUserIdState(newId);
setUser(null); // Clear old user immediately so stale data isn't shown
// Notify listeners immediately of account switch
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: newId } }));
}
try { try {
// ── Run session update + new user profile fetch IN PARALLEL ───────── // First, get the JWT token for this demo user by calling the switch API
const [_, newUserRes] = await Promise.all([ const switchRes = await fetch(`/api/user/switch?id=${newId}`);
// Fire-and-forget session token update const switchData = await switchRes.json();
fetch(`/api/users/${newId}/session`, {
method: "POST", if (!switchRes.ok || !switchData.token) {
headers: { "Content-Type": "application/json" }, throw new Error("Switch failed");
body: JSON.stringify({ session_token: sessionToken }), }
}).catch(() => {}), // ignore failure — non-critical
// Fetch the new user's profile immediately const sessionToken = switchData.token; // This is the JWT
fetch(`/api/users/${newId}`), const actualUserId = switchData.user_id;
]);
// ── Update cookie + localStorage immediately (optimistic) ───────────────
document.cookie = `auth_token=${sessionToken}; path=/; max-age=2592000; SameSite=Lax`;
localStorage.setItem("active_session_token", sessionToken);
localStorage.setItem("activeUserId", actualUserId);
// Update refs and state before awaiting network
activeUserIdRef.current = actualUserId;
setUserIdState(actualUserId);
setUser(null); // Clear old user immediately so stale data isn't shown
// Notify listeners immediately of account switch
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: actualUserId } }));
}
// Fetch the new user's profile immediately using the new token
const newUserRes = await fetchWithAuth(`/api/users/${actualUserId}`);
if (newUserRes && newUserRes.ok) { if (newUserRes && newUserRes.ok) {
const data = await newUserRes.json(); const data = await newUserRes.json();
setUser(data); setUser(data);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: newId, user: data } })); window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: actualUserId, user: data } }));
} }
} else { } else {
// Fallback: use the regular fetch path await fetchUserProfile(actualUserId);
await fetchUserProfile(newId);
} }
} catch { } catch (err) {
// Network error — fall back to full fetch // Network error
console.error("Failed to switch user", err);
await fetchUserProfile(newId); await fetchUserProfile(newId);
} finally { } finally {
setSwitching(false); setSwitching(false);

27
src/lib/apiClient.ts Normal file
View File

@@ -0,0 +1,27 @@
export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const isServer = typeof window === 'undefined';
if (isServer) {
// If we're on the server-side, we should pass along headers from next/headers
// or just let it fall back to standard fetch if no auth is needed for server-side fetches.
// For this app, most data fetching seems to be client-side.
return fetch(url, options);
}
// Get token from localStorage
const token = localStorage.getItem('active_session_token');
// Prepare headers
const headers = new Headers(options.headers || {});
// Apply token if it exists
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
// Perform fetch with updated headers
return fetch(url, {
...options,
headers,
});
}

View File

@@ -1,3 +1,4 @@
import { fetchWithAuth } from "@/lib/apiClient";
import { create } from 'zustand' import { create } from 'zustand'
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware' import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
import { get, set, del } from 'idb-keyval' import { get, set, del } from 'idb-keyval'
@@ -252,7 +253,7 @@ export const useCBTStore = create<CBTState>()(
if (!payload) return null; if (!payload) return null;
try { try {
const res = await fetch(`/api/attempts/submit`, { const res = await fetchWithAuth(`/api/attempts/submit`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),