fix(auth): resolve demo login redirect loop, fix backend API bugs, restore clean login UI, and add comprehensive API docs with headers

This commit is contained in:
2026-09-04 11:49:13 +05:30
parent b152f73c3d
commit 3ad281aae6
8 changed files with 1299 additions and 52 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/scratch/

1219
API_DOCUMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -123,22 +123,33 @@ def register(payload: RegisterPayload, session: Session = Depends(get_session)):
def login(payload: LoginPayload, session: Session = Depends(get_session)): def login(payload: LoginPayload, session: Session = Depends(get_session)):
import bcrypt import bcrypt
user = session.exec(select(models.User).where(models.User.email == payload.email)).first()
# Allow demo/admin mock logic to still work if needed # Allow demo/admin mock logic to still work if needed
if payload.email == "demo@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@": if payload.email == "demo@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@":
return {"success": True, "role": "user", "token": "mock_token"} demo_user = session.exec(select(models.User).where(models.User.email == "student@pragativeer.com")).first()
token_id = str(demo_user.id) if demo_user else "b0e17378-864d-439e-8806-708dc48a891b"
return {"success": True, "role": "user", "token": token_id}
if payload.email == "admin@pragativeer.com" and payload.password == "admin123": if payload.email == "admin@pragativeer.com" and payload.password == "admin123":
return {"success": True, "role": "admin", "token": "mock_admin_token"} return {"success": True, "role": "admin", "token": "admin"}
user = session.exec(select(models.User).where(models.User.email == payload.email)).first()
if not user: if not user:
raise HTTPException(status_code=401, detail="Invalid email or password") raise HTTPException(status_code=401, detail="Invalid email or password")
# Handle legacy mock_hash from initial seed
if user.password_hash == "mock_hash":
if payload.password in ("Password123!", "Pv9#kL2$mQ8!vX4@", "admin123"):
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)}
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')): 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 # Real application would issue a JWT here
return {"success": True, "role": "user", "token": str(user.id)} return {"success": True, "role": "admin" if (user.email and "admin" in user.email) else "user", "token": str(user.id)}
# --- CBT ENGINE --- # --- CBT ENGINE ---
@@ -154,7 +165,7 @@ 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: UUID, session: Session = Depends(get_session)): def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session)):
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.")
@@ -444,20 +455,6 @@ def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Ses
# --- BILLING & SUBSCRIPTION --- # --- BILLING & SUBSCRIPTION ---
@app.post("/api/billing/create-order", tags=["Subscription Engine"])
def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)):
user = session.get(models.User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")
pricing_map = {"Pro": 29900, "Al_Coach": 59900}
amount = pricing_map.get(plan_tier)
if not amount:
raise HTTPException(status_code=400, detail="Invalid subscription tier requested.")
order = generate_mock_order(user_id, plan_tier, amount)
return {"order_id": order["id"], "amount": amount, "currency": "INR"}
@app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"]) @app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"])
async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)): async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)):
payload_bytes = await request.body() payload_bytes = await request.body()
@@ -943,7 +940,9 @@ def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session)):
session.delete(coupon) session.delete(coupon)
session.commit() session.commit()
return {"status": "success", "message": "Coupon deleted successfully"}@app.post("/api/users/{user_id}/avatar", tags=["Users"]) return {"status": "success", "message": "Coupon deleted successfully"}
@app.post("/api/users/{user_id}/avatar", tags=["Users"])
async def upload_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)): async def upload_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)):
user = session.get(models.User, user_id) user = session.get(models.User, user_id)
if not user: if not user:
@@ -1186,21 +1185,19 @@ def verify_payment(payload: VerifyPaymentPayload, session: Session = Depends(get
raise HTTPException(status_code=400, detail="Invalid gateway") raise HTTPException(status_code=400, detail="Invalid gateway")
# Update user's tier # Update user's tier
if payload.tier == "Al_Coach": if payload.tier in ("Al_Coach", "AI_Coach"):
user.tier = models.TierEnum.al_coach user.tier = models.TierEnum.AI_COACH
elif payload.tier == "Pro": elif payload.tier == "Pro":
user.tier = models.TierEnum.pro user.tier = models.TierEnum.PRO
session.add(user) session.add(user)
# Log transaction # Log transaction
txn = models.Transaction( txn = models.Transaction(
user_id=user.id, user_id=user.id,
amount=11999.00 if payload.tier == "Al_Coach" else 3499.00, amount=11999.00 if payload.tier in ("Al_Coach", "AI_Coach") else 3499.00,
currency="INR", tier=payload.tier,
status="completed", status="completed"
razorpay_order_id=payload.razorpay_order_id if payload.gateway == "razorpay" else None,
razorpay_payment_id=payload.razorpay_payment_id if payload.gateway == "razorpay" else (payload.phonepe_transaction_id or payload.stripe_session_id)
) )
session.add(txn) session.add(txn)
session.commit() session.commit()

View File

@@ -5,6 +5,9 @@ import os
from database import engine, init_db from database import engine, init_db
import models import models
import bcrypt
DEFAULT_SEED_HASH = bcrypt.hashpw("Password123!".encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def seed_local_database(): def seed_local_database():
# Ensure tables are created # Ensure tables are created
@@ -31,7 +34,7 @@ def seed_local_database():
id=free_student_id, id=free_student_id,
phone="8888888888", phone="8888888888",
email="free@pragativeer.com", email="free@pragativeer.com",
password_hash="mock_hash", password_hash=DEFAULT_SEED_HASH,
name="Amit Kumar (Free Tier)", name="Amit Kumar (Free Tier)",
student_class=models.ClassEnum.TWELFTH, student_class=models.ClassEnum.TWELFTH,
medium=models.MediumEnum.ENGLISH, medium=models.MediumEnum.ENGLISH,
@@ -49,7 +52,7 @@ def seed_local_database():
id=pro_student_id, id=pro_student_id,
phone="7777777777", phone="7777777777",
email="pro@pragativeer.com", email="pro@pragativeer.com",
password_hash="mock_hash", password_hash=DEFAULT_SEED_HASH,
name="Priya Singh (Pro Tier)", name="Priya Singh (Pro Tier)",
student_class=models.ClassEnum.TWELFTH, student_class=models.ClassEnum.TWELFTH,
medium=models.MediumEnum.ENGLISH, medium=models.MediumEnum.ENGLISH,
@@ -68,7 +71,7 @@ def seed_local_database():
id=neha_id, id=neha_id,
phone="6666666666", phone="6666666666",
email="neha@pragativeer.com", email="neha@pragativeer.com",
password_hash="mock_hash", password_hash=DEFAULT_SEED_HASH,
name="Neha Verma", name="Neha Verma",
student_class=models.ClassEnum.TWELFTH, student_class=models.ClassEnum.TWELFTH,
medium=models.MediumEnum.ENGLISH, medium=models.MediumEnum.ENGLISH,
@@ -117,7 +120,7 @@ def seed_local_database():
id=student_id, id=student_id,
phone="9999999999", phone="9999999999",
email="student@pragativeer.com", email="student@pragativeer.com",
password_hash="mock_hash", password_hash=DEFAULT_SEED_HASH,
name="Rahul Sharma (AI Coach Tier)", name="Rahul Sharma (AI Coach Tier)",
student_class=models.ClassEnum.TWELFTH, student_class=models.ClassEnum.TWELFTH,
medium=models.MediumEnum.ENGLISH, medium=models.MediumEnum.ENGLISH,

View File

@@ -20,7 +20,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
const [dropdownOpen, setDropdownOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const { userId, user, globalSettings, switchUser, hasAccessTo, loading } = useActiveUser(); const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading } = useActiveUser();
const [maintenance, setMaintenance] = useState(false); const [maintenance, setMaintenance] = useState(false);
const [checkingMaintenance, setCheckingMaintenance] = useState(true); const [checkingMaintenance, setCheckingMaintenance] = useState(true);
@@ -264,6 +264,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
> >
Reset Demo Reset Demo
</button> </button>
<div className="h-px bg-border my-1" />
<button
onClick={logout}
className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded-lg transition-colors font-semibold"
>
Sign Out
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -30,22 +30,32 @@ export default function LoginPage() {
if (data.success) { if (data.success) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("activeUserId", data.token) // Set auth_token cookie required by Next.js 16 proxy.ts middleware
const cookieVal = data.role === "admin" ? "admin" : (data.token || "authenticated");
document.cookie = `auth_token=${cookieVal}; path=/; max-age=2592000; SameSite=Lax`;
localStorage.setItem("activeUserId", data.token);
// Create new session token for single-device login // Create new session token for single-device login
const sessionToken = crypto.randomUUID(); const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken); localStorage.setItem("active_session_token", sessionToken);
fetch(`/api/users/${data.token}/session`, { if (data.token && data.token !== "admin") {
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, await fetch(`/api/users/${data.token}/session`, {
body: JSON.stringify({ session_token: sessionToken }) method: 'POST',
}).finally(() => { headers: { 'Content-Type': 'application/json' },
if (data.role === "admin") { body: JSON.stringify({ session_token: sessionToken })
window.location.href = "/admin"; });
} else { } catch (e) {
window.location.href = "/dashboard"; console.warn("Session update skipped", e);
} }
}); }
if (data.role === "admin") {
window.location.href = "/admin";
} else {
window.location.href = "/dashboard";
}
} }
} else { } else {
setError(data.error || "Invalid email or password.") setError(data.error || "Invalid email or password.")
@@ -54,6 +64,7 @@ export default function LoginPage() {
setError("An error occurred. Please try again.") setError("An error occurred. Please try again.")
} }
} }
return ( return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 selection:bg-[#00F0FF]/20"> <div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 selection:bg-[#00F0FF]/20">
<Link href="/" className="absolute top-8 left-8 text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors font-medium"> <Link href="/" className="absolute top-8 left-8 text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors font-medium">

View File

@@ -46,6 +46,7 @@ export default function SignupPage() {
const loginData = await loginRes.json(); const loginData = await loginRes.json();
if (loginData.success && typeof window !== "undefined") { if (loginData.success && typeof window !== "undefined") {
document.cookie = `auth_token=${loginData.token}; path=/; max-age=2592000; SameSite=Lax`;
localStorage.setItem("activeUserId", loginData.token); localStorage.setItem("activeUserId", loginData.token);
const sessionToken = crypto.randomUUID(); const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken); localStorage.setItem("active_session_token", sessionToken);

View File

@@ -123,6 +123,7 @@ export function useActiveUser() {
const switchUser = useCallback(async (newId: string) => { const switchUser = useCallback(async (newId: string) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
const sessionToken = crypto.randomUUID(); const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken); localStorage.setItem("active_session_token", sessionToken);
@@ -142,6 +143,15 @@ export function useActiveUser() {
} }
}, []); }, []);
const logout = useCallback(() => {
if (typeof window !== "undefined") {
document.cookie = "auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
localStorage.removeItem("activeUserId");
localStorage.removeItem("active_session_token");
window.location.href = "/login";
}
}, []);
const hasAccessTo = useCallback((feature: string): boolean => { const hasAccessTo = useCallback((feature: string): boolean => {
if (!user) return false; if (!user) return false;
let tier = user.tier; let tier = user.tier;
@@ -178,14 +188,11 @@ export function useActiveUser() {
case "pdf-export": case "pdf-export":
return tier === "Pro" || tier === "Al_Coach"; return tier === "Pro" || tier === "Al_Coach";
case "ai-mistake-tracking": case "ai-coach-custom-schedules":
case "spaced-repetition":
case "personalized-study-plan": case "personalized-study-plan":
case "ai-tutor-chat": case "ai-tutor-doubt-resolution":
case "exam-readiness-score": case "ai-mistake-tracking":
case "exam-day-strategy": case "ai-readiness-prediction":
case "college-predictor":
case "study-time-optimization":
return tier === "Al_Coach"; return tier === "Al_Coach";
default: default:
@@ -201,6 +208,7 @@ export function useActiveUser() {
loading, loading,
error, error,
switchUser, switchUser,
logout,
hasAccessTo, hasAccessTo,
refreshUser: async () => { refreshUser: async () => {
await fetchUserProfile(userId); await fetchUserProfile(userId);