diff --git a/alembic/versions/0006_add_is_verified_column.py b/alembic/versions/0006_add_is_verified_column.py new file mode 100644 index 0000000..5060d6f --- /dev/null +++ b/alembic/versions/0006_add_is_verified_column.py @@ -0,0 +1,32 @@ +"""add is_verified to users + +Revision ID: cd84ef65d459 +Revises: 0005 +Create Date: 2026-01-20 19:09:51.730690 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "cd84ef65d459" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add the 'is_verified' column to users table + op.add_column( + "users", + sa.Column( + "is_verified", sa.Boolean(), nullable=False, server_default=sa.false() + ), + ) + + +def downgrade() -> None: + # Remove the 'is_verified' column if we downgrade + op.drop_column("users", "is_verified") diff --git a/app/api/admin.py b/app/api/admin.py index a05007a..35a4a1f 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -5,6 +5,7 @@ from app.core.dependencies import require_admin, require_admin_or_sworik from app.db.database import get_db +from app.models.language import Language from app.models.user import User, UserRole from app.schemas.admin import ( FlaggedTranscriptionResponse, @@ -161,6 +162,16 @@ async def get_usage_analytics( return admin_service.get_usage_analytics(days=days) +@router.get("/languages") +async def get_languages( + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + """Get all available languages for admin forms.""" + languages = db.query(Language).order_by(Language.name).all() + return [{"id": lang.id, "name": lang.name, "code": lang.code} for lang in languages] + + @router.get("/system/health") async def get_admin_system_health( current_user: User = Depends(require_admin_or_sworik), diff --git a/app/api/auth.py b/app/api/auth.py index 2c5387e..823b9d5 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,34 +1,128 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from sqlalchemy.orm import Session +# New imports for Verification and Email +from app.core import security from app.core.dependencies import get_current_active_user, require_admin from app.db.database import get_db from app.models.user import User, UserRole from app.schemas.auth import Token, UserCreate, UserCreateAdmin, UserLogin, UserResponse from app.services.auth_service import AuthService +from app.services.mail import send_auth_email router = APIRouter(prefix="/auth", tags=["authentication"]) -@router.post( - "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED -) -async def register(user_data: UserCreate, db: Session = Depends(get_db)): - """Register a new user.""" - auth_service = AuthService(db) - user = auth_service.create_user(user_data) - return user +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register( + user_data: UserCreate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """Stage a new user and send verification email with embedded data.""" + + existing_user = db.query(User).filter(User.email == user_data.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + token_payload = { + "email": user_data.email, + "name": user_data.name, + "password": user_data.password, + } + + token = security.create_task_token( + email=user_data.email, + scope="complete_registration", # New scope for this workflow + expires_minutes=1440, + # If your create_task_token supports extra data, add it here: + data={"name": user_data.name, "password": user_data.password}, + ) + + background_tasks.add_task(send_auth_email, user_data.email, token, "verify") + + return {"message": "Please check your email to complete account creation."} @router.post("/login", response_model=Token) async def login(login_data: UserLogin, db: Session = Depends(get_db)): """Login user and return access token.""" auth_service = AuthService(db) + user, access_token = auth_service.login(login_data) return {"access_token": access_token, "token_type": "bearer"} +@router.get("/verify-email") +async def verify_email(token: str, db: Session = Depends(get_db)): + """Confirm email and FINALLY create the user record.""" + # Extract the data using the NEW helper and the NEW scope + reg_data = security.verify_task_token_with_data( + token, expected_scope="complete_registration" + ) + + if not reg_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired verification link.", + ) + + # Prevent duplicate creation if they click the link twice + email = reg_data["email"] + existing_user = db.query(User).filter(User.email == email).first() + if existing_user: + return {"message": "Account already created. You can login now."} + + # Use AuthService to create the user in the database + auth_service = AuthService(db) + user = auth_service.create_user( + UserCreate(email=email, name=reg_data["name"], password=reg_data["password"]) + ) + + # Mark as verified immediately + user.is_verified = True + db.commit() + + return {"message": "Account created successfully! You can now login."} + + +@router.post("/password-reset/request") +async def request_password_reset( + email: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db) +) -> dict: + """Request a password reset link.""" + user = db.query(User).filter(User.email == email).first() + if user: + token = security.create_task_token( + email=user.email, scope="password_reset", expires_minutes=30 + ) + background_tasks.add_task(send_auth_email, user.email, token, "reset") + + # Always return 200 for security to prevent email enumeration + return {"message": "If the email is registered, a reset link has been sent."} + + +@router.post("/password-reset/confirm") +async def confirm_password_reset( + token: str, new_password: str, db: Session = Depends(get_db) +) -> dict: + """Reset password using the token from the email link.""" + email = security.verify_task_token(token, expected_scope="password_reset") + if not email: + raise HTTPException(status_code=400, detail="Invalid or expired reset link") + + user = db.query(User).filter(User.email == email).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Use AuthService to handle hashing and saving + auth_service = AuthService(db) + auth_service.update_password(user, new_password) + + return {"message": "Password updated successfully."} + + @router.get("/me", response_model=UserResponse) async def get_current_user_info(current_user: User = Depends(get_current_active_user)): """Get current user information.""" diff --git a/app/api/scripts.py b/app/api/scripts.py index 76e6372..71bf496 100644 --- a/app/api/scripts.py +++ b/app/api/scripts.py @@ -47,11 +47,11 @@ async def get_random_script( duration_category=duration_category, language_id=language_id ) - script = script_service.get_random_script(request) + script = script_service.get_random_script(request, user_id=current_user.id) if not script: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"No scripts found for duration category '{duration_category.value}'", + detail=f"No unrecorded scripts found for duration category '{duration_category.value}'. You may have recorded all available scripts in this category.", ) return script diff --git a/app/core/config.py b/app/core/config.py index 514e82a..c9b6a4e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -68,7 +68,8 @@ def from_env(cls) -> "StorageConfig": class Settings(BaseSettings): - model_config = ConfigDict(env_file=".env", case_sensitive=True) + # model_config = ConfigDict(env_file=".env", case_sensitive=True) + model_config = ConfigDict(env_file=".env", case_sensitive=True, extra="ignore") APP_NAME: str = "Shrutik (শ্রুতিক)" DEBUG: bool = False @@ -154,6 +155,19 @@ class Settings(BaseSettings): ) EXPORT_DAILY_DOWNLOAD_LIMIT_ADMIN: int = -1 # Unlimited downloads for admins + # mail setting + + MAIL_USERNAME: str = "" + MAIL_PASSWORD: str = "" + MAIL_FROM: str = "noreply@shrutik.com" + MAIL_PORT: int = 587 + MAIL_SERVER: str = "smtp.gmail.com" + MAIL_FROM_NAME: str = "Shrutik Platform" + MAIL_STARTTLS: bool = True + MAIL_SSL_TLS: bool = False + FRONTEND_URL: str = "http://localhost:3000" + BACKEND_URL: str = "http://localhost:8000" + def __init__(self, **kwargs): """Initialize settings with validation.""" super().__init__(**kwargs) diff --git a/app/core/security.py b/app/core/security.py index a3a6bdf..d6b3713 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -9,23 +9,14 @@ from app.models.user import UserRole try: - # Try bcrypt first with a simple test pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12) - pwd_context.hash("test"[:72]) # Ensure test password is within bcrypt limits + pwd_context.hash("test"[:72]) except Exception: - try: - # Fallback to argon2 - pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") - pwd_context.hash("test") - except Exception: - # Final fallback to pbkdf2 (always available) - pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a plain password against its hash.""" try: - # Truncate password if too long for bcrypt (72 bytes limit) if len(plain_password.encode("utf-8")) > 72: plain_password = plain_password[:72] return pwd_context.verify(plain_password, hashed_password) @@ -34,59 +25,71 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def get_password_hash(password: str) -> str: - """Generate password hash.""" if len(password.encode("utf-8")) > 72: password = password.encode("utf-8")[:72].decode("utf-8", errors="ignore") return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: - """Create JWT access token.""" to_encode = data.copy() - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) - - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) ) - return encoded_jwt + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_task_token( + email: str, scope: str, expires_minutes: int, data: dict = None +) -> str: + """Create a token that can optionally carry extra data (like name/password).""" + to_encode = { + "sub": email, + "scope": scope, + "exp": datetime.utcnow() + timedelta(minutes=expires_minutes), + } + if data: + to_encode.update({"extra_data": data}) # Pack name/password here + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) def verify_token(token: str) -> dict: - """Verify and decode JWT token.""" try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] - ) - return payload + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) except JWTError: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) -class PermissionChecker: - """Role-based permission checker.""" +def verify_task_token_with_data(token: str, expected_scope: str) -> dict: + """Verify the token and return the full dictionary of data.""" + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + if payload.get("scope") != expected_scope: + return None + + # Return everything needed to create the user + return { + "email": payload.get("sub"), + "name": payload.get("extra_data", {}).get("name"), + "password": payload.get("extra_data", {}).get("password"), + } + except jwt.JWTError: + return None + +class PermissionChecker: ROLE_PERMISSIONS = { - UserRole.CONTRIBUTOR: { - "record_voice", - "transcribe_audio", - "view_own_data", - }, + UserRole.CONTRIBUTOR: {"record_voice", "transcribe_audio", "view_own_data"}, UserRole.ADMIN: { "record_voice", "transcribe_audio", "view_own_data", - "manage_users", # Only admins manage users - "manage_scripts", # Only admins manage scripts + "manage_users", + "manage_scripts", "view_all_data", "quality_review", "view_statistics", @@ -96,38 +99,42 @@ class PermissionChecker: "record_voice", "transcribe_audio", "view_own_data", - "view_all_data", # Can view data for research - "quality_review", # Can review quality - "view_statistics", # Can view stats - "export_data", # Can export for research - "access_raw_data", # Can access raw data for development + "view_all_data", + "quality_review", + "view_statistics", + "export_data", + "access_raw_data", }, } @classmethod def has_permission(cls, user_role: UserRole, permission: str) -> bool: - """Check if a user role has a specific permission.""" return permission in cls.ROLE_PERMISSIONS.get(user_role, set()) @classmethod def require_permission(cls, user_role: UserRole, permission: str) -> None: - """Raise exception if user doesn't have required permission.""" if not cls.has_permission(user_role, permission): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Insufficient permissions. Required: {permission}", - ) + raise HTTPException(status_code=403, detail="Insufficient permissions") @classmethod def require_role( cls, user_role: UserRole, required_roles: Union[UserRole, list[UserRole]] ) -> None: - """Raise exception if user doesn't have required role.""" - if isinstance(required_roles, UserRole): - required_roles = [required_roles] - - if user_role not in required_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Insufficient role. Required one of: {[role.value for role in required_roles]}", - ) + roles = ( + [required_roles] if isinstance(required_roles, UserRole) else required_roles + ) + if user_role not in roles: + raise HTTPException(status_code=403, detail="Insufficient role") + + +def verify_task_token(token: str, expected_scope: str) -> Optional[str]: + """Verify the token scope and return the email (sub).""" + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + if payload.get("scope") != expected_scope: + return None + return payload.get("sub") + except (jwt.JWTError, AttributeError): + return None diff --git a/app/models/user.py b/app/models/user.py index 94f50ac..ddc9e98 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,6 +1,6 @@ import enum -from sqlalchemy import JSON, Column, DateTime, Enum, Integer, String +from sqlalchemy import JSON, Boolean, Column, DateTime, Enum, Integer, String from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -23,6 +23,8 @@ class User(Base): role = Column( Enum(UserRole), default=UserRole.CONTRIBUTOR, nullable=False, index=True ) + is_verified = Column(Boolean, default=False, nullable=False) + meta_data = Column(JSON, default=dict) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column( diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 39b3387..d2f891e 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -17,10 +17,8 @@ def __init__(self, db: Session): self.db = db def create_user(self, user_data: UserCreate) -> User: - """Create a new user with hashed password (public registration - always CONTRIBUTOR).""" - existing_user = ( - self.db.query(User).filter(User.email == user_data.email).first() - ) + """Create a new user. For this workflow, they are verified by default.""" + existing_user = self.get_user_by_email(user_data.email) if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -32,7 +30,8 @@ def create_user(self, user_data: UserCreate) -> User: name=user_data.name, email=user_data.email, password_hash=hashed_password, - role=UserRole.CONTRIBUTOR, # Always CONTRIBUTOR for public registration + role=UserRole.CONTRIBUTOR, + is_verified=True, # CHANGED: They just verified via the token link! ) self.db.add(db_user) @@ -57,6 +56,7 @@ def create_user_with_role(self, user_data: UserCreateAdmin) -> User: email=user_data.email, password_hash=hashed_password, role=user_data.role, # Use specified role (admin operation) + is_verified=True, ) self.db.add(db_user) @@ -85,6 +85,13 @@ def login(self, login_data: UserLogin) -> tuple[User, str]: headers={"WWW-Authenticate": "Bearer"}, ) + # --- Verification Check --- + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email address not verified. Please check your inbox.", + ) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.email, "user_id": user.id, "role": user.role.value}, @@ -93,6 +100,16 @@ def login(self, login_data: UserLogin) -> tuple[User, str]: return user, access_token + def update_password(self, user: User, new_password: str) -> User: + """Update user password (used for password reset flow).""" + user.password_hash = get_password_hash(new_password) + # Often a password reset should also ensure the user is verified + user.is_verified = True + + self.db.commit() + self.db.refresh(user) + return user + def get_user_by_email(self, email: str) -> Optional[User]: """Get user by email.""" return self.db.query(User).filter(User.email == email).first() diff --git a/app/services/mail.py b/app/services/mail.py new file mode 100644 index 0000000..5c11ca8 --- /dev/null +++ b/app/services/mail.py @@ -0,0 +1,44 @@ +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema + +from app.core.config import settings + +conf = ConnectionConfig( + MAIL_USERNAME=settings.MAIL_USERNAME, + MAIL_PASSWORD=settings.MAIL_PASSWORD, + MAIL_FROM=settings.MAIL_FROM, + MAIL_PORT=settings.MAIL_PORT, + MAIL_SERVER=settings.MAIL_SERVER, + MAIL_FROM_NAME=settings.MAIL_FROM_NAME, + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + USE_CREDENTIALS=True, + VALIDATE_CERTS=True, +) + + +async def send_auth_email(email: str, token: str, task_type: str): + + if task_type == "verify": + + link = f"{settings.FRONTEND_URL}/verify-email?token={token}" + subject = "Complete Your Registration - Shrutik" + else: + link = f"{settings.FRONTEND_URL}/reset-password?token={token}" + subject = "Password Reset Request - Shrutik" + + html = f""" +
Please click the link below to {task_type} your account:
+ Confirm Action +If you didn't request this, please ignore this email.
+ """ + + message = MessageSchema( + subject=subject, + recipients=[email], + body=html, + subtype="html", + ) + + fm = FastMail(conf) + await fm.send_message(message) diff --git a/app/services/script_service.py b/app/services/script_service.py index 058d766..c4cf2bf 100644 --- a/app/services/script_service.py +++ b/app/services/script_service.py @@ -191,8 +191,10 @@ def delete_script(self, script_id: int) -> bool: self.db.commit() return True - def get_random_script(self, request: RandomScriptRequest) -> Optional[Script]: - """Get a random script based on duration category and language.""" + def get_random_script( + self, request: RandomScriptRequest, user_id: Optional[int] = None + ) -> Optional[Script]: + """Get a random script based on duration category and language, excluding scripts already recorded by the user.""" # Default to Bangla if no language specified language_id = request.language_id if language_id is None: @@ -200,27 +202,55 @@ def get_random_script(self, request: RandomScriptRequest) -> Optional[Script]: if bangla_lang: language_id = bangla_lang.id else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Bangla language not found and no language_id specified", - ) + # Try to create the Bengali language if it doesn't exist + try: + bangla_lang = Language(code="bn", name="Bengali") + self.db.add(bangla_lang) + self.db.commit() + self.db.refresh(bangla_lang) + language_id = bangla_lang.id + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create Bengali language: {str(e)}", + ) + + # Validate that the language exists + language = self.db.query(Language).filter(Language.id == language_id).first() + if not language: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Language with ID {language_id} not found", + ) - # Query for scripts matching criteria - scripts = ( - self.db.query(Script) - .filter( - and_( - Script.duration_category == request.duration_category, - Script.language_id == language_id, - ) + # Base query for scripts matching criteria + scripts_query = self.db.query(Script).filter( + and_( + Script.duration_category == request.duration_category, + Script.language_id == language_id, ) - .all() ) + # If user_id is provided, exclude scripts already recorded by this user + if user_id is not None: + from app.models.voice_recording import VoiceRecording + + # Get script IDs that this user has already recorded + recorded_script_ids = ( + self.db.query(VoiceRecording.script_id) + .filter(VoiceRecording.user_id == user_id) + .distinct() + ) + + # Exclude already recorded scripts + scripts_query = scripts_query.filter(~Script.id.in_(recorded_script_ids)) + + scripts = scripts_query.all() + if not scripts: return None - # Return random script + # Return random script from available unrecorded scripts return random.choice(scripts) def validate_script_content( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d74adb..8dbf113 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; import ProtectedRoute from './components/auth/ProtectedRoute'; import Layout from './components/layout/Layout'; @@ -7,7 +7,12 @@ import LoginForm from './components/auth/LoginForm'; import RegisterForm from './components/auth/RegisterForm'; import LoadingSpinner from './components/common/LoadingSpinner'; -// Lazy load pages for better performance +// Auth Pages +import { VerifyEmail } from './pages/VerifyEmail'; +import { ResetPassword } from './pages/ResetPassword'; +import { ForgotPasswordRequest } from './pages/ForgotPasswordRequest'; + +// Lazy load pages const HomePage = lazy(() => import('./pages/HomePage')); const RecordPage = lazy(() => import('./pages/RecordPage')); const TranscribePage = lazy(() => import('./pages/TranscribePage')); @@ -20,8 +25,13 @@ function App() {