banu4prasad commited on
Commit
4d60cbf
·
1 Parent(s): 82d7136

feat: forgot password admin-assisted

Browse files
README.md CHANGED
@@ -66,6 +66,8 @@ SECRET_KEY=your-32-char-secret-key
66
  ALGORITHM=HS256
67
  ACCESS_TOKEN_EXPIRE_MINUTES=480
68
  CORS_ORIGINS=http://localhost:5173
 
 
69
  UPLOAD_DIR=uploads
70
  AUTH_COOKIE_SECURE=false
71
  AUTH_COOKIE_SAMESITE=lax
@@ -128,6 +130,8 @@ ALGORITHM=HS256
128
  ACCESS_TOKEN_EXPIRE_MINUTES=480
129
  UPLOAD_DIR=/data/uploads
130
  CORS_ORIGINS=http://localhost:5173
 
 
131
  AUTH_COOKIE_SECURE=true
132
  AUTH_COOKIE_SAMESITE=none
133
  CLOUDINARY_CLOUD_NAME=your_cloud_name
 
66
  ALGORITHM=HS256
67
  ACCESS_TOKEN_EXPIRE_MINUTES=480
68
  CORS_ORIGINS=http://localhost:5173
69
+ FRONTEND_URL=http://localhost:5173
70
+ PASSWORD_RESET_EXPIRE_MINUTES=30
71
  UPLOAD_DIR=uploads
72
  AUTH_COOKIE_SECURE=false
73
  AUTH_COOKIE_SAMESITE=lax
 
130
  ACCESS_TOKEN_EXPIRE_MINUTES=480
131
  UPLOAD_DIR=/data/uploads
132
  CORS_ORIGINS=http://localhost:5173
133
+ FRONTEND_URL=https://gateprep6901.vercel.app
134
+ PASSWORD_RESET_EXPIRE_MINUTES=30
135
  AUTH_COOKIE_SECURE=true
136
  AUTH_COOKIE_SAMESITE=none
137
  CLOUDINARY_CLOUD_NAME=your_cloud_name
backend/app/api/deps.py CHANGED
@@ -31,8 +31,8 @@ def get_current_user(
31
  if not user:
32
  raise HTTPException(status_code=401, detail="User not found")
33
 
34
- # Single session check — token must match current session
35
- if session_id and user.current_session_id and session_id != user.current_session_id:
36
  raise HTTPException(
37
  status_code=401,
38
  detail="Session expired. You logged in from another device."
 
31
  if not user:
32
  raise HTTPException(status_code=401, detail="User not found")
33
 
34
+ # Single session check — token must match the currently stored session.
35
+ if not session_id or user.current_session_id != session_id:
36
  raise HTTPException(
37
  status_code=401,
38
  detail="Session expired. You logged in from another device."
backend/app/api/routes/admin.py CHANGED
@@ -1,7 +1,9 @@
1
  import json
2
  import os
3
  import uuid
 
4
  from typing import Any, List, Optional
 
5
  from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
6
  from fastapi.concurrency import run_in_threadpool
7
  from sqlalchemy import func
@@ -9,8 +11,9 @@ from sqlalchemy.orm import Session
9
  from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
10
  from app.core.database import get_db
11
  from app.core.config import settings
 
12
  from app.api.deps import require_admin
13
- from app.models.models import User, Test, Question, QuestionType, UserRole
14
  from app.services.answer_utils import is_valid_nat_answer, normalize_question_type, split_answer_tokens
15
  from app.services.pdf_service import extract_questions_from_pdf
16
  from app.services.cloudinary_service import upload_image, delete_image
@@ -33,6 +36,14 @@ PDF_IMPORT_BLOCKING_WARNINGS = {
33
  }
34
 
35
 
 
 
 
 
 
 
 
 
36
  async def _save_pdf_upload(pdf_file: UploadFile, path: str) -> None:
37
  file_size = 0
38
 
@@ -125,6 +136,42 @@ def toggle_status(user_id: int, db: Session = Depends(get_db), _=Depends(require
125
  return {"message": f"User {'activated' if user.is_active else 'deactivated'}", "is_active": user.is_active}
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # ── Tests ─────────────────────────────────────────────────────────
129
 
130
  @router.get("/tests")
 
1
  import json
2
  import os
3
  import uuid
4
+ from datetime import datetime, timedelta, timezone
5
  from typing import Any, List, Optional
6
+ from urllib.parse import urlencode
7
  from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
8
  from fastapi.concurrency import run_in_threadpool
9
  from sqlalchemy import func
 
11
  from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
12
  from app.core.database import get_db
13
  from app.core.config import settings
14
+ from app.core.security import generate_password_reset_token, hash_password_reset_token
15
  from app.api.deps import require_admin
16
+ from app.models.models import PasswordResetToken, User, Test, Question, QuestionType, UserRole
17
  from app.services.answer_utils import is_valid_nat_answer, normalize_question_type, split_answer_tokens
18
  from app.services.pdf_service import extract_questions_from_pdf
19
  from app.services.cloudinary_service import upload_image, delete_image
 
36
  }
37
 
38
 
39
+ def _utcnow() -> datetime:
40
+ return datetime.now(timezone.utc)
41
+
42
+
43
+ def _password_reset_url(token: str) -> str:
44
+ return f"{settings.FRONTEND_URL.rstrip('/')}/reset-password?{urlencode({'token': token})}"
45
+
46
+
47
  async def _save_pdf_upload(pdf_file: UploadFile, path: str) -> None:
48
  file_size = 0
49
 
 
136
  return {"message": f"User {'activated' if user.is_active else 'deactivated'}", "is_active": user.is_active}
137
 
138
 
139
+ @router.post("/users/{user_id}/password-reset")
140
+ def create_password_reset_link(user_id: int, db: Session = Depends(get_db), current=Depends(require_admin)):
141
+ user = db.query(User).filter(User.id == user_id).first()
142
+ if not user:
143
+ raise HTTPException(status_code=404, detail="User not found")
144
+
145
+ now = _utcnow()
146
+ (
147
+ db.query(PasswordResetToken)
148
+ .filter(
149
+ PasswordResetToken.user_id == user.id,
150
+ PasswordResetToken.used_at.is_(None),
151
+ )
152
+ .update({PasswordResetToken.used_at: now}, synchronize_session=False)
153
+ )
154
+
155
+ token = generate_password_reset_token()
156
+ expires_at = now + timedelta(minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES)
157
+ reset_token = PasswordResetToken(
158
+ user_id=user.id,
159
+ created_by=current.id,
160
+ token_hash=hash_password_reset_token(token),
161
+ expires_at=expires_at,
162
+ )
163
+ db.add(reset_token)
164
+ db.commit()
165
+
166
+ return {
167
+ "user_id": user.id,
168
+ "email": user.email,
169
+ "full_name": user.full_name,
170
+ "reset_url": _password_reset_url(token),
171
+ "expires_at": expires_at,
172
+ }
173
+
174
+
175
  # ── Tests ─────────────────────────────────────────────────────────
176
 
177
  @router.get("/tests")
backend/app/api/routes/auth.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from fastapi import APIRouter, Depends, HTTPException, Request, Response
2
  from sqlalchemy.orm import Session
3
  from pydantic import BaseModel, EmailStr
@@ -10,8 +11,9 @@ from app.core.security import (
10
  create_access_token,
11
  decode_token,
12
  generate_session_id,
 
13
  )
14
- from app.models.models import User, UserRole
15
  from app.api.deps import get_current_user
16
 
17
  router = APIRouter(prefix="/auth", tags=["Authentication"])
@@ -26,6 +28,10 @@ class LoginRequest(BaseModel):
26
  email: EmailStr
27
  password: str
28
 
 
 
 
 
29
  class AuthUserResponse(BaseModel):
30
  id: int
31
  email: EmailStr
@@ -33,6 +39,16 @@ class AuthUserResponse(BaseModel):
33
  full_name: str
34
 
35
 
 
 
 
 
 
 
 
 
 
 
36
  def _auth_cookie_max_age_seconds() -> int:
37
  return settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
38
 
@@ -138,6 +154,51 @@ def logout(request: Request, response: Response, db: Session = Depends(get_db)):
138
  return {"message": "Logged out"}
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  # ── Me ────────────────────────────────────────────────────────────
142
 
143
  @router.get("/me")
 
1
+ from datetime import datetime, timezone
2
  from fastapi import APIRouter, Depends, HTTPException, Request, Response
3
  from sqlalchemy.orm import Session
4
  from pydantic import BaseModel, EmailStr
 
11
  create_access_token,
12
  decode_token,
13
  generate_session_id,
14
+ hash_password_reset_token,
15
  )
16
+ from app.models.models import PasswordResetToken, User, UserRole
17
  from app.api.deps import get_current_user
18
 
19
  router = APIRouter(prefix="/auth", tags=["Authentication"])
 
28
  email: EmailStr
29
  password: str
30
 
31
+ class ResetPasswordRequest(BaseModel):
32
+ token: str
33
+ password: str
34
+
35
  class AuthUserResponse(BaseModel):
36
  id: int
37
  email: EmailStr
 
39
  full_name: str
40
 
41
 
42
+ def _utcnow() -> datetime:
43
+ return datetime.now(timezone.utc)
44
+
45
+
46
+ def _as_utc(value: datetime) -> datetime:
47
+ if value.tzinfo is None:
48
+ return value.replace(tzinfo=timezone.utc)
49
+ return value.astimezone(timezone.utc)
50
+
51
+
52
  def _auth_cookie_max_age_seconds() -> int:
53
  return settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
54
 
 
154
  return {"message": "Logged out"}
155
 
156
 
157
+ # ── Reset Password ────────────────────────────────────────────────
158
+
159
+ @router.post("/reset-password")
160
+ def reset_password(payload: ResetPasswordRequest, db: Session = Depends(get_db)):
161
+ token = payload.token.strip()
162
+ if not token:
163
+ raise HTTPException(status_code=400, detail="Invalid or expired reset link")
164
+ if len(payload.password) < 6:
165
+ raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
166
+
167
+ reset_token = (
168
+ db.query(PasswordResetToken)
169
+ .filter(PasswordResetToken.token_hash == hash_password_reset_token(token))
170
+ .first()
171
+ )
172
+ now = _utcnow()
173
+ if (
174
+ not reset_token
175
+ or reset_token.used_at is not None
176
+ or _as_utc(reset_token.expires_at) < now
177
+ ):
178
+ raise HTTPException(status_code=400, detail="Invalid or expired reset link")
179
+
180
+ user = db.query(User).filter(User.id == reset_token.user_id).first()
181
+ if not user:
182
+ raise HTTPException(status_code=400, detail="Invalid or expired reset link")
183
+
184
+ user.hashed_password = hash_password(payload.password)
185
+ user.current_session_id = None
186
+ user.current_ip = None
187
+ reset_token.used_at = now
188
+ (
189
+ db.query(PasswordResetToken)
190
+ .filter(
191
+ PasswordResetToken.user_id == user.id,
192
+ PasswordResetToken.used_at.is_(None),
193
+ PasswordResetToken.id != reset_token.id,
194
+ )
195
+ .update({PasswordResetToken.used_at: now}, synchronize_session=False)
196
+ )
197
+ db.commit()
198
+
199
+ return {"message": "Password reset successful. Please sign in."}
200
+
201
+
202
  # ── Me ────────────────────────────────────────────────────────────
203
 
204
  @router.get("/me")
backend/app/core/config.py CHANGED
@@ -76,6 +76,8 @@ class Settings(BaseSettings):
76
  ALGORITHM: str = "HS256"
77
  ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
78
  CORS_ORIGINS: str = "http://localhost:5173,https://gateprep6901.vercel.app"
 
 
79
  UPLOAD_DIR: str = "uploads"
80
  AUTH_COOKIE_NAME: str = "access_token"
81
  AUTH_COOKIE_SECURE: bool = True
 
76
  ALGORITHM: str = "HS256"
77
  ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
78
  CORS_ORIGINS: str = "http://localhost:5173,https://gateprep6901.vercel.app"
79
+ FRONTEND_URL: str = "http://localhost:5173"
80
+ PASSWORD_RESET_EXPIRE_MINUTES: int = 30
81
  UPLOAD_DIR: str = "uploads"
82
  AUTH_COOKIE_NAME: str = "access_token"
83
  AUTH_COOKIE_SECURE: bool = True
backend/app/core/security.py CHANGED
@@ -1,5 +1,6 @@
1
  from datetime import datetime, timedelta, timezone
2
  from typing import Optional
 
3
  import secrets
4
  from jose import JWTError, jwt
5
  from passlib.context import CryptContext
@@ -17,6 +18,12 @@ def generate_session_id() -> str:
17
  """Unique session ID stored in DB — invalidates old sessions."""
18
  return secrets.token_hex(32)
19
 
 
 
 
 
 
 
20
  def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
21
  to_encode = data.copy()
22
  expire = datetime.now(timezone.utc) + (
 
1
  from datetime import datetime, timedelta, timezone
2
  from typing import Optional
3
+ import hashlib
4
  import secrets
5
  from jose import JWTError, jwt
6
  from passlib.context import CryptContext
 
18
  """Unique session ID stored in DB — invalidates old sessions."""
19
  return secrets.token_hex(32)
20
 
21
+ def generate_password_reset_token() -> str:
22
+ return secrets.token_urlsafe(48)
23
+
24
+ def hash_password_reset_token(token: str) -> str:
25
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
26
+
27
  def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
28
  to_encode = data.copy()
29
  expire = datetime.now(timezone.utc) + (
backend/app/models/models.py CHANGED
@@ -44,6 +44,26 @@ class User(Base):
44
  practice_counters = relationship("PracticeAttemptCounter", back_populates="user", cascade="all, delete-orphan")
45
  bookmarks = relationship("Bookmark", back_populates="user", cascade="all, delete-orphan")
46
  checklist_items = relationship("ChecklistProgress", back_populates="user", cascade="all, delete-orphan")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  class TestSeries(Base):
 
44
  practice_counters = relationship("PracticeAttemptCounter", back_populates="user", cascade="all, delete-orphan")
45
  bookmarks = relationship("Bookmark", back_populates="user", cascade="all, delete-orphan")
46
  checklist_items = relationship("ChecklistProgress", back_populates="user", cascade="all, delete-orphan")
47
+ password_reset_tokens = relationship(
48
+ "PasswordResetToken",
49
+ foreign_keys="PasswordResetToken.user_id",
50
+ back_populates="user",
51
+ cascade="all, delete-orphan",
52
+ )
53
+
54
+
55
+ class PasswordResetToken(Base):
56
+ __tablename__ = "password_reset_tokens"
57
+ id = Column(Integer, primary_key=True, index=True)
58
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
59
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
60
+ token_hash = Column(String(64), unique=True, index=True, nullable=False)
61
+ expires_at = Column(DateTime(timezone=True), nullable=False)
62
+ used_at = Column(DateTime(timezone=True), nullable=True)
63
+ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
64
+
65
+ user = relationship("User", foreign_keys=[user_id], back_populates="password_reset_tokens")
66
+ creator = relationship("User", foreign_keys=[created_by])
67
 
68
 
69
  class TestSeries(Base):
backend/tests/test_password_reset.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from datetime import datetime, timedelta, timezone
3
+ from urllib.parse import parse_qs, urlparse
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.testclient import TestClient
7
+ from sqlalchemy import create_engine
8
+ from sqlalchemy.orm import sessionmaker
9
+ from sqlalchemy.pool import StaticPool
10
+
11
+ from app.api.routes import admin as admin_routes
12
+ from app.api.routes import auth as auth_routes
13
+ from app.core.config import settings
14
+ from app.core.database import Base, get_db
15
+ from app.core.security import (
16
+ generate_password_reset_token,
17
+ hash_password,
18
+ hash_password_reset_token,
19
+ )
20
+ from app.models.models import PasswordResetToken, User, UserRole
21
+
22
+
23
+ class PasswordResetTests(unittest.TestCase):
24
+ def setUp(self):
25
+ self.original_frontend_url = settings.FRONTEND_URL
26
+ self.original_cookie_secure = settings.AUTH_COOKIE_SECURE
27
+ settings.FRONTEND_URL = "https://frontend.test"
28
+ settings.AUTH_COOKIE_SECURE = True
29
+ self.addCleanup(self._restore_settings)
30
+
31
+ self.engine = create_engine(
32
+ "sqlite:///:memory:",
33
+ connect_args={"check_same_thread": False},
34
+ poolclass=StaticPool,
35
+ )
36
+ Base.metadata.create_all(self.engine)
37
+ self.SessionLocal = sessionmaker(bind=self.engine)
38
+
39
+ db = self.SessionLocal()
40
+ try:
41
+ admin = User(
42
+ email="admin@example.com",
43
+ full_name="Admin User",
44
+ hashed_password=hash_password("adminsecret"),
45
+ role=UserRole.admin,
46
+ is_active=True,
47
+ )
48
+ user = User(
49
+ email="student@example.com",
50
+ full_name="Student User",
51
+ hashed_password=hash_password("oldsecret"),
52
+ role=UserRole.aspirant,
53
+ is_active=True,
54
+ )
55
+ db.add_all([admin, user])
56
+ db.commit()
57
+ db.refresh(admin)
58
+ db.refresh(user)
59
+ self.admin_id = admin.id
60
+ self.user_id = user.id
61
+ finally:
62
+ db.close()
63
+
64
+ app = FastAPI()
65
+ app.include_router(auth_routes.router)
66
+ app.include_router(admin_routes.router)
67
+
68
+ def override_get_db():
69
+ db = self.SessionLocal()
70
+ try:
71
+ yield db
72
+ finally:
73
+ db.close()
74
+
75
+ app.dependency_overrides[get_db] = override_get_db
76
+ self.admin_client = TestClient(app, base_url="https://testserver")
77
+ self.user_client = TestClient(app, base_url="https://testserver")
78
+ self.public_client = TestClient(app, base_url="https://testserver")
79
+
80
+ def _restore_settings(self):
81
+ settings.FRONTEND_URL = self.original_frontend_url
82
+ settings.AUTH_COOKIE_SECURE = self.original_cookie_secure
83
+
84
+ def _login_admin(self):
85
+ response = self.admin_client.post(
86
+ "/auth/login",
87
+ json={"email": "admin@example.com", "password": "adminsecret"},
88
+ )
89
+ self.assertEqual(response.status_code, 200)
90
+
91
+ def _create_reset_link(self):
92
+ response = self.admin_client.post(f"/admin/users/{self.user_id}/password-reset")
93
+ self.assertEqual(response.status_code, 200)
94
+ return response.json()
95
+
96
+ def _token_from_url(self, reset_url: str) -> str:
97
+ token = parse_qs(urlparse(reset_url).query).get("token", [None])[0]
98
+ self.assertTrue(token)
99
+ return token
100
+
101
+ def test_admin_generated_link_resets_password_once_and_invalidates_session(self):
102
+ self._login_admin()
103
+ user_login = self.user_client.post(
104
+ "/auth/login",
105
+ json={"email": "student@example.com", "password": "oldsecret"},
106
+ )
107
+ self.assertEqual(user_login.status_code, 200)
108
+ self.assertEqual(self.user_client.get("/auth/me").status_code, 200)
109
+
110
+ reset_payload = self._create_reset_link()
111
+ self.assertEqual(reset_payload["email"], "student@example.com")
112
+ self.assertTrue(reset_payload["reset_url"].startswith(f"{settings.FRONTEND_URL}/reset-password?"))
113
+ self.assertNotIn("token_hash", reset_payload)
114
+
115
+ token = self._token_from_url(reset_payload["reset_url"])
116
+ reset_response = self.public_client.post(
117
+ "/auth/reset-password",
118
+ json={"token": token, "password": "newsecret"},
119
+ )
120
+ self.assertEqual(reset_response.status_code, 200)
121
+
122
+ self.assertEqual(self.user_client.get("/auth/me").status_code, 401)
123
+ old_login = self.user_client.post(
124
+ "/auth/login",
125
+ json={"email": "student@example.com", "password": "oldsecret"},
126
+ )
127
+ self.assertEqual(old_login.status_code, 401)
128
+ new_login = self.user_client.post(
129
+ "/auth/login",
130
+ json={"email": "student@example.com", "password": "newsecret"},
131
+ )
132
+ self.assertEqual(new_login.status_code, 200)
133
+
134
+ reuse_response = self.public_client.post(
135
+ "/auth/reset-password",
136
+ json={"token": token, "password": "anothersecret"},
137
+ )
138
+ self.assertEqual(reuse_response.status_code, 400)
139
+
140
+ def test_new_admin_link_invalidates_previous_unused_link(self):
141
+ self._login_admin()
142
+ first = self._token_from_url(self._create_reset_link()["reset_url"])
143
+ second = self._token_from_url(self._create_reset_link()["reset_url"])
144
+
145
+ first_response = self.public_client.post(
146
+ "/auth/reset-password",
147
+ json={"token": first, "password": "firstsecret"},
148
+ )
149
+ self.assertEqual(first_response.status_code, 400)
150
+
151
+ second_response = self.public_client.post(
152
+ "/auth/reset-password",
153
+ json={"token": second, "password": "secondsecret"},
154
+ )
155
+ self.assertEqual(second_response.status_code, 200)
156
+
157
+ def test_expired_reset_token_is_rejected(self):
158
+ token = generate_password_reset_token()
159
+ db = self.SessionLocal()
160
+ try:
161
+ db.add(
162
+ PasswordResetToken(
163
+ user_id=self.user_id,
164
+ created_by=self.admin_id,
165
+ token_hash=hash_password_reset_token(token),
166
+ expires_at=datetime.now(timezone.utc) - timedelta(minutes=1),
167
+ )
168
+ )
169
+ db.commit()
170
+ finally:
171
+ db.close()
172
+
173
+ response = self.public_client.post(
174
+ "/auth/reset-password",
175
+ json={"token": token, "password": "newsecret"},
176
+ )
177
+ self.assertEqual(response.status_code, 400)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ unittest.main()
docker-compose.yml CHANGED
@@ -26,6 +26,8 @@ services:
26
  SECRET_KEY: dev-secret-key-change-in-production
27
  ALGORITHM: HS256
28
  ACCESS_TOKEN_EXPIRE_MINUTES: 480
 
 
29
  UPLOAD_DIR: /app/uploads
30
  CORS_ORIGINS: http://localhost:5173
31
  AUTH_COOKIE_SECURE: "false"
 
26
  SECRET_KEY: dev-secret-key-change-in-production
27
  ALGORITHM: HS256
28
  ACCESS_TOKEN_EXPIRE_MINUTES: 480
29
+ FRONTEND_URL: http://localhost:5173
30
+ PASSWORD_RESET_EXPIRE_MINUTES: 30
31
  UPLOAD_DIR: /app/uploads
32
  CORS_ORIGINS: http://localhost:5173
33
  AUTH_COOKIE_SECURE: "false"
frontend/src/App.jsx CHANGED
@@ -7,6 +7,8 @@ import Spinner from './components/shared/Spinner'
7
 
8
  const LoginPage = lazy(() => import('./pages/Login'))
9
  const RegisterPage = lazy(() => import('./pages/Register'))
 
 
10
  const PendingPage = lazy(() => import('./pages/Pending'))
11
 
12
  const AdminDashboard = lazy(() => import('./pages/AdminDashboard'))
@@ -33,6 +35,8 @@ export default function App() {
33
  {/* Public */}
34
  <Route path="/login" element={<GuestRoute><LoginPage /></GuestRoute>} />
35
  <Route path="/register" element={<GuestRoute><RegisterPage /></GuestRoute>} />
 
 
36
  <Route path="/pending" element={<PendingPage />} />
37
 
38
  {/* Admin */}
 
7
 
8
  const LoginPage = lazy(() => import('./pages/Login'))
9
  const RegisterPage = lazy(() => import('./pages/Register'))
10
+ const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
11
+ const ResetPassword = lazy(() => import('./pages/ResetPassword'))
12
  const PendingPage = lazy(() => import('./pages/Pending'))
13
 
14
  const AdminDashboard = lazy(() => import('./pages/AdminDashboard'))
 
35
  {/* Public */}
36
  <Route path="/login" element={<GuestRoute><LoginPage /></GuestRoute>} />
37
  <Route path="/register" element={<GuestRoute><RegisterPage /></GuestRoute>} />
38
+ <Route path="/forgot-password" element={<GuestRoute><ForgotPassword /></GuestRoute>} />
39
+ <Route path="/reset-password" element={<ResetPassword />} />
40
  <Route path="/pending" element={<PendingPage />} />
41
 
42
  {/* Admin */}
frontend/src/api/api.js CHANGED
@@ -4,6 +4,7 @@ import api from './client'
4
  export const authAPI = {
5
  register: (data) => api.post('/auth/register', data),
6
  login: (data) => api.post('/auth/login', data),
 
7
  logout: () => api.post('/auth/logout'),
8
  me: () => api.get('/auth/me'),
9
  }
@@ -13,6 +14,7 @@ export const adminAPI = {
13
  getUsers: () => api.get('/admin/users'),
14
  updateRole: (id, role) => api.patch(`/admin/users/${id}/role`, { role }),
15
  toggleStatus: (id) => api.patch(`/admin/users/${id}/status`),
 
16
  getTests: () => api.get('/admin/tests'),
17
  getTest: (id) => api.get(`/admin/tests/${id}`),
18
  createTest: (form) => api.post('/admin/tests', form, { headers: { 'Content-Type': 'multipart/form-data' } }),
 
4
  export const authAPI = {
5
  register: (data) => api.post('/auth/register', data),
6
  login: (data) => api.post('/auth/login', data),
7
+ resetPassword: (data) => api.post('/auth/reset-password', data),
8
  logout: () => api.post('/auth/logout'),
9
  me: () => api.get('/auth/me'),
10
  }
 
14
  getUsers: () => api.get('/admin/users'),
15
  updateRole: (id, role) => api.patch(`/admin/users/${id}/role`, { role }),
16
  toggleStatus: (id) => api.patch(`/admin/users/${id}/status`),
17
+ createPasswordReset: (id) => api.post(`/admin/users/${id}/password-reset`),
18
  getTests: () => api.get('/admin/tests'),
19
  getTest: (id) => api.get(`/admin/tests/${id}`),
20
  createTest: (form) => api.post('/admin/tests', form, { headers: { 'Content-Type': 'multipart/form-data' } }),
frontend/src/pages/AdminUsers.jsx CHANGED
@@ -2,9 +2,8 @@ import { useEffect, useState } from 'react'
2
  import Layout from '../components/shared/Layout'
3
  import { adminAPI } from '../api/api'
4
  import toast from 'react-hot-toast'
5
- import { Users, Search, ShieldCheck, UserX, UserCheck, RefreshCw } from 'lucide-react'
6
  import Spinner from '../components/shared/Spinner'
7
- import clsx from 'clsx'
8
 
9
  const ROLES = ['admin', 'aspirant', 'user']
10
  const roleStyle = { admin: 'badge-blue', aspirant: 'badge-green', user: 'badge-amber' }
@@ -14,6 +13,7 @@ export default function AdminUsers() {
14
  const [loading, setLoading] = useState(true)
15
  const [search, setSearch] = useState('')
16
  const [updating, setUpdating] = useState({})
 
17
 
18
  const load = () => {
19
  setLoading(true)
@@ -47,6 +47,30 @@ export default function AdminUsers() {
47
  }
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  const filtered = users.filter(u =>
51
  u.full_name.toLowerCase().includes(search.toLowerCase()) ||
52
  u.email.toLowerCase().includes(search.toLowerCase())
@@ -138,6 +162,13 @@ export default function AdminUsers() {
138
  {u.is_active ? 'Disable' : 'Enable'}
139
  </button>
140
  )}
 
 
 
 
 
 
 
141
  </>
142
  )}
143
  </div>
@@ -151,6 +182,52 @@ export default function AdminUsers() {
151
  </table>
152
  </div>
153
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  </div>
155
  </Layout>
156
  )
 
2
  import Layout from '../components/shared/Layout'
3
  import { adminAPI } from '../api/api'
4
  import toast from 'react-hot-toast'
5
+ import { Copy, KeyRound, Search, UserX, UserCheck, RefreshCw, X } from 'lucide-react'
6
  import Spinner from '../components/shared/Spinner'
 
7
 
8
  const ROLES = ['admin', 'aspirant', 'user']
9
  const roleStyle = { admin: 'badge-blue', aspirant: 'badge-green', user: 'badge-amber' }
 
13
  const [loading, setLoading] = useState(true)
14
  const [search, setSearch] = useState('')
15
  const [updating, setUpdating] = useState({})
16
+ const [resetLink, setResetLink] = useState(null)
17
 
18
  const load = () => {
19
  setLoading(true)
 
47
  }
48
  }
49
 
50
+ const createResetLink = async (userId) => {
51
+ const key = `reset-${userId}`
52
+ setUpdating(u => ({ ...u, [key]: true }))
53
+ try {
54
+ const res = await adminAPI.createPasswordReset(userId)
55
+ setResetLink(res.data)
56
+ toast.success('Reset link created')
57
+ } catch (err) {
58
+ toast.error(err.response?.data?.detail || 'Failed to create reset link')
59
+ } finally {
60
+ setUpdating(u => ({ ...u, [key]: false }))
61
+ }
62
+ }
63
+
64
+ const copyResetLink = async () => {
65
+ if (!resetLink?.reset_url) return
66
+ try {
67
+ await navigator.clipboard.writeText(resetLink.reset_url)
68
+ toast.success('Reset link copied')
69
+ } catch {
70
+ toast.error('Could not copy link')
71
+ }
72
+ }
73
+
74
  const filtered = users.filter(u =>
75
  u.full_name.toLowerCase().includes(search.toLowerCase()) ||
76
  u.email.toLowerCase().includes(search.toLowerCase())
 
162
  {u.is_active ? 'Disable' : 'Enable'}
163
  </button>
164
  )}
165
+ <button
166
+ onClick={() => createResetLink(u.id)}
167
+ disabled={updating[`reset-${u.id}`]}
168
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-sky-500/10 border border-sky-500/20 text-sky-400 text-xs font-medium hover:bg-sky-500/20 transition-colors disabled:opacity-50"
169
+ >
170
+ {updating[`reset-${u.id}`] ? <Spinner size={13} /> : <KeyRound size={13} />} Reset
171
+ </button>
172
  </>
173
  )}
174
  </div>
 
182
  </table>
183
  </div>
184
  )}
185
+
186
+ {resetLink && (
187
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
188
+ <div className="gate-card w-full max-w-lg p-5 animate-slide-up">
189
+ <div className="flex items-start justify-between gap-4 mb-4">
190
+ <div>
191
+ <h2 className="text-lg font-bold text-white">Password Reset Link</h2>
192
+ <p className="text-sm text-slate-400 mt-1">{resetLink.full_name} · {resetLink.email}</p>
193
+ </div>
194
+ <button
195
+ onClick={() => setResetLink(null)}
196
+ className="p-1.5 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-colors"
197
+ aria-label="Close"
198
+ >
199
+ <X size={18} />
200
+ </button>
201
+ </div>
202
+
203
+ <label className="label">Reset Link</label>
204
+ <input
205
+ value={resetLink.reset_url}
206
+ readOnly
207
+ className="input font-mono text-xs"
208
+ aria-label="Password reset link"
209
+ />
210
+ <p className="text-xs text-slate-500 mt-2">
211
+ Expires {new Date(resetLink.expires_at).toLocaleString('en-IN', {
212
+ day: '2-digit',
213
+ month: 'short',
214
+ year: 'numeric',
215
+ hour: '2-digit',
216
+ minute: '2-digit',
217
+ })}
218
+ </p>
219
+
220
+ <div className="flex items-center justify-end gap-3 mt-5">
221
+ <button onClick={() => setResetLink(null)} className="btn-ghost">
222
+ Close
223
+ </button>
224
+ <button onClick={copyResetLink} className="btn-primary flex items-center gap-2">
225
+ <Copy size={15} /> Copy Link
226
+ </button>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ )}
231
  </div>
232
  </Layout>
233
  )
frontend/src/pages/ForgotPassword.jsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from 'react-router-dom'
2
+ import { ArrowLeft, ShieldCheck } from 'lucide-react'
3
+
4
+ export default function ForgotPasswordPage() {
5
+ return (
6
+ <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg)' }}>
7
+ <div className="w-full max-w-md animate-slide-up">
8
+ <div className="flex items-center gap-2 mb-8">
9
+ <div className="w-8 h-8 rounded bg-sky-600 flex items-center justify-center">
10
+ <span className="font-bold text-white">G</span>
11
+ </div>
12
+ <span className="font-bold text-lg" style={{ color: 'var(--text)' }}>GATEPrep</span>
13
+ </div>
14
+
15
+ <h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text)' }}>Account recovery</h2>
16
+ <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>
17
+ Contact your administrator to receive a secure reset link.
18
+ </p>
19
+
20
+ <div className="gate-card p-6">
21
+ <div className="flex items-start gap-4">
22
+ <div className="w-10 h-10 rounded bg-sky-500/15 border border-sky-500/25 flex items-center justify-center flex-shrink-0">
23
+ <ShieldCheck size={18} className="text-sky-400" />
24
+ </div>
25
+ <div>
26
+ <h3 className="font-semibold text-base" style={{ color: 'var(--text)' }}>Admin-assisted reset</h3>
27
+ <p className="text-sm mt-1 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
28
+ An administrator can generate a one-time link from the Users page.
29
+ </p>
30
+ </div>
31
+ </div>
32
+
33
+ <Link to="/login" className="btn-ghost w-full mt-6 flex items-center justify-center gap-2">
34
+ <ArrowLeft size={15} /> Back to sign in
35
+ </Link>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ )
40
+ }
frontend/src/pages/Login.jsx CHANGED
@@ -82,7 +82,12 @@ export default function LoginPage() {
82
  placeholder="you@example.com" className="input" autoFocus />
83
  </div>
84
  <div>
85
- <label className="label">Password</label>
 
 
 
 
 
86
  <div className="relative">
87
  <input type={show ? 'text' : 'password'} required value={form.password}
88
  onChange={handle('password')} placeholder="••••••••" className="input pr-10" />
 
82
  placeholder="you@example.com" className="input" autoFocus />
83
  </div>
84
  <div>
85
+ <div className="flex items-center justify-between mb-1.5">
86
+ <label className="label mb-0">Password</label>
87
+ <Link to="/forgot-password" className="text-xs text-sky-400 hover:text-sky-300 font-medium">
88
+ Forgot password?
89
+ </Link>
90
+ </div>
91
  <div className="relative">
92
  <input type={show ? 'text' : 'password'} required value={form.password}
93
  onChange={handle('password')} placeholder="••••••••" className="input pr-10" />
frontend/src/pages/ResetPassword.jsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
3
+ import toast from 'react-hot-toast'
4
+ import { ArrowLeft, Eye, EyeOff, KeyRound } from 'lucide-react'
5
+ import { authAPI } from '../api/api'
6
+ import Spinner from '../components/shared/Spinner'
7
+
8
+ export default function ResetPasswordPage() {
9
+ const [searchParams] = useSearchParams()
10
+ const navigate = useNavigate()
11
+ const token = searchParams.get('token') || ''
12
+ const [form, setForm] = useState({ password: '', confirm: '' })
13
+ const [show, setShow] = useState(false)
14
+ const [loading, setLoading] = useState(false)
15
+
16
+ const handle = k => e => setForm(f => ({ ...f, [k]: e.target.value }))
17
+
18
+ const submit = async e => {
19
+ e.preventDefault()
20
+ if (!token) { toast.error('Invalid reset link'); return }
21
+ if (form.password !== form.confirm) { toast.error('Passwords do not match'); return }
22
+ if (form.password.length < 6) { toast.error('Password must be at least 6 characters'); return }
23
+
24
+ setLoading(true)
25
+ try {
26
+ await authAPI.resetPassword({ token, password: form.password })
27
+ toast.success('Password updated. Sign in again.')
28
+ navigate('/login', { replace: true })
29
+ } catch (err) {
30
+ toast.error(err.response?.data?.detail || 'Password reset failed')
31
+ } finally {
32
+ setLoading(false)
33
+ }
34
+ }
35
+
36
+ return (
37
+ <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg)' }}>
38
+ <div className="w-full max-w-md animate-slide-up">
39
+ <div className="flex items-center gap-2 mb-8">
40
+ <div className="w-8 h-8 rounded bg-sky-600 flex items-center justify-center">
41
+ <span className="font-bold text-white">G</span>
42
+ </div>
43
+ <span className="font-bold text-lg" style={{ color: 'var(--text)' }}>GATEPrep</span>
44
+ </div>
45
+
46
+ <h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text)' }}>Reset password</h2>
47
+ <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>Choose a new password for your account</p>
48
+
49
+ <div className="gate-card p-6">
50
+ {!token ? (
51
+ <div className="text-center">
52
+ <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>This reset link is invalid.</p>
53
+ <Link to="/login" className="btn-ghost w-full flex items-center justify-center gap-2">
54
+ <ArrowLeft size={15} /> Back to sign in
55
+ </Link>
56
+ </div>
57
+ ) : (
58
+ <form onSubmit={submit} className="space-y-4">
59
+ <div>
60
+ <label className="label">New Password</label>
61
+ <div className="relative">
62
+ <input
63
+ type={show ? 'text' : 'password'}
64
+ required
65
+ value={form.password}
66
+ onChange={handle('password')}
67
+ placeholder="Min. 6 characters"
68
+ className="input pr-10"
69
+ autoComplete="new-password"
70
+ autoFocus
71
+ />
72
+ <button
73
+ type="button"
74
+ onClick={() => setShow(s => !s)}
75
+ className="absolute right-3 top-1/2 -translate-y-1/2"
76
+ style={{ color: 'var(--text-muted)' }}
77
+ >
78
+ {show ? <EyeOff size={16} /> : <Eye size={16} />}
79
+ </button>
80
+ </div>
81
+ </div>
82
+ <div>
83
+ <label className="label">Confirm Password</label>
84
+ <input
85
+ type={show ? 'text' : 'password'}
86
+ required
87
+ value={form.confirm}
88
+ onChange={handle('confirm')}
89
+ placeholder="Re-enter password"
90
+ className="input"
91
+ autoComplete="new-password"
92
+ />
93
+ </div>
94
+ <button
95
+ type="submit"
96
+ disabled={loading}
97
+ className="btn-primary w-full flex items-center justify-center gap-2 mt-2"
98
+ >
99
+ {loading ? <Spinner size={15} /> : <KeyRound size={15} />}
100
+ {loading ? 'Updating password...' : 'Update Password'}
101
+ </button>
102
+ </form>
103
+ )}
104
+ </div>
105
+
106
+ <p className="text-center mt-4 text-sm" style={{ color: 'var(--text-muted)' }}>
107
+ Remember your password?{' '}
108
+ <Link to="/login" className="text-sky-400 hover:text-sky-300 font-medium">Sign in</Link>
109
+ </p>
110
+ </div>
111
+ </div>
112
+ )
113
+ }