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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
1219
API_DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/users/${data.token}/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 })
|
||||||
}).finally(() => {
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Session update skipped", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.role === "admin") {
|
if (data.role === "admin") {
|
||||||
window.location.href = "/admin";
|
window.location.href = "/admin";
|
||||||
} else {
|
} else {
|
||||||
window.location.href = "/dashboard";
|
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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user