Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions alembic/versions/0006_add_is_verified_column.py
Original file line number Diff line number Diff line change
@@ -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")
11 changes: 11 additions & 0 deletions app/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
112 changes: 103 additions & 9 deletions app/api/auth.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down
4 changes: 2 additions & 2 deletions app/api/scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading