Spaces:
Running
Running
Commit ·
4d60cbf
1
Parent(s): 82d7136
feat: forgot password admin-assisted
Browse files- README.md +4 -0
- backend/app/api/deps.py +2 -2
- backend/app/api/routes/admin.py +48 -1
- backend/app/api/routes/auth.py +62 -1
- backend/app/core/config.py +2 -0
- backend/app/core/security.py +7 -0
- backend/app/models/models.py +20 -0
- backend/tests/test_password_reset.py +181 -0
- docker-compose.yml +2 -0
- frontend/src/App.jsx +4 -0
- frontend/src/api/api.js +2 -0
- frontend/src/pages/AdminUsers.jsx +79 -2
- frontend/src/pages/ForgotPassword.jsx +40 -0
- frontend/src/pages/Login.jsx +6 -1
- frontend/src/pages/ResetPassword.jsx +113 -0
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
|
| 35 |
-
if 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 {
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|