From 29f4b168ea13be2e69263826ba5026c1d8a90da3 Mon Sep 17 00:00:00 2001 From: Ifran Ruhin Date: Sat, 17 Jan 2026 19:09:42 +0600 Subject: [PATCH 1/3] feat: update the language switching ans script endpoint behaviour --- app/api/admin.py | 11 + app/api/auth.py | 112 +++++++++- app/api/scripts.py | 4 +- app/core/config.py | 16 +- app/core/security.py | 129 +++++------ app/models/user.py | 4 +- app/services/auth_service.py | 27 ++- app/services/mail.py | 44 ++++ app/services/script_service.py | 62 ++++-- frontend/src/App.tsx | 32 ++- frontend/src/components/admin/ScriptForm.tsx | 35 ++- frontend/src/components/auth/LoginForm.tsx | 62 ++++-- frontend/src/components/auth/RegisterForm.tsx | 44 ++-- frontend/src/contexts/AuthContext.tsx | 77 +++---- frontend/src/pages/ForgotPasswordRequest.tsx | 87 ++++++++ frontend/src/pages/ResetPassword.tsx | 205 ++++++++++++++++++ frontend/src/pages/VerifyEmail.tsx | 97 +++++++++ frontend/src/schemas/script.schema.ts | 2 +- frontend/src/services/admin.service.ts | 4 + frontend/src/services/apiClient.ts | 17 +- frontend/src/services/auth.service.ts | 77 ++++--- scripts/init-db.py | 22 +- scripts/simple-init.py | 14 +- 23 files changed, 945 insertions(+), 239 deletions(-) create mode 100644 app/services/mail.py create mode 100644 frontend/src/pages/ForgotPasswordRequest.tsx create mode 100644 frontend/src/pages/ResetPassword.tsx create mode 100644 frontend/src/pages/VerifyEmail.tsx 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""" +

Shrutik Voice Platform

+

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() { + {/* Public Auth Routes */} } /> } /> + } /> + } /> + } /> + } /> + + {/* Protected Application Routes */} }> + {} + } /> + }> @@ -41,8 +56,9 @@ function App() { } /> + }> @@ -52,7 +68,7 @@ function App() { } /> }> @@ -62,7 +78,7 @@ function App() { } /> }> @@ -72,7 +88,7 @@ function App() { } /> }> @@ -81,6 +97,8 @@ function App() { } /> + + } /> diff --git a/frontend/src/components/admin/ScriptForm.tsx b/frontend/src/components/admin/ScriptForm.tsx index f7c682f..3ffbcd0 100644 --- a/frontend/src/components/admin/ScriptForm.tsx +++ b/frontend/src/components/admin/ScriptForm.tsx @@ -1,9 +1,16 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { UseFormRegister, FieldErrors, UseFormWatch } from 'react-hook-form'; import { CheckCircleIcon, XCircleIcon, ClockIcon } from '@heroicons/react/24/outline'; import { ScriptValidation } from '../../types/api'; import { ScriptFormData } from '../../schemas/script.schema'; import { FormTextarea, FormSelect, Button } from '../ui'; +import { adminService } from '../../services/admin.service'; + +interface Language { + id: number; + name: string; + code: string; +} interface ScriptFormProps { register: UseFormRegister; @@ -27,6 +34,25 @@ const ScriptForm: React.FC = ({ onSubmit, }) => { const formText = watch('text'); + const [languages, setLanguages] = useState([]); + + useEffect(() => { + const fetchLanguages = async () => { + try { + const data = await adminService.getLanguages(); + setLanguages(data); + } catch (error) { + console.error('Failed to fetch languages:', error); + // Fallback to default languages if API fails + setLanguages([ + { id: 1, name: 'English', code: 'en' }, + { id: 2, name: 'Bengali', code: 'bn' }, + ]); + } + }; + + fetchLanguages(); + }, []); return (
@@ -56,8 +82,11 @@ const ScriptForm: React.FC = ({
- - + {languages.map(language => ( + + ))}
diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index fd61e8a..acd2880 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; import { useTranslation } from 'react-i18next'; @@ -19,7 +19,8 @@ const LoginForm: React.FC = () => { const { login } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const from = location.state?.from?.pathname || '/'; + + const from = location.state?.from?.pathname || '/dashboard'; const { register, @@ -30,7 +31,6 @@ const LoginForm: React.FC = () => { resolver: zodResolver(loginSchema), }); - // Show success message from registration redirect useEffect(() => { if (location.state?.message) { setSuccessMessage(location.state.message); @@ -40,25 +40,35 @@ const LoginForm: React.FC = () => { const onSubmit = async (data: LoginFormData) => { setIsLoading(true); + setSuccessMessage(''); try { - const success = await login(data.email, data.password); + const result = await login(data.email, data.password); - if (success.user) { - navigate(from, { replace: true }); + if (result && result.user) { + const targetPath = from === '/' || !from ? '/dashboard' : from; + navigate(targetPath, { replace: true }); } else { - setFormError('root', { message: 'Invalid email or password' }); + setFormError('root', { + message: 'Login successful, but user profile could not be loaded.', + }); } } catch (error: any) { - // Axios Error Parsing - if (error.response) { + const errorMessage = error.response?.data?.detail; + + if (error.response?.status === 422) { + setFormError('root', { message: 'Server configuration error: Invalid data format.' }); + } else if (error.response?.status === 403) { setFormError('root', { - message: error.response.data?.error.message || 'Server returned an error', + message: errorMessage || 'Account not verified. Please check your email inbox.', }); - } else if (error.request) { - setFormError('root', { message: 'No response from server. It might be offline.' }); + } else if (error.response?.status === 401) { + setFormError('root', { message: 'Invalid email or password' }); } else { - setFormError('root', { message: error.message || 'Network Error' }); + setFormError('root', { + message: + typeof errorMessage === 'string' ? errorMessage : 'An unexpected error occurred.', + }); } } finally { setIsLoading(false); @@ -67,12 +77,13 @@ const LoginForm: React.FC = () => { return (
-
+
+
-

+

{t('login-title')}

@@ -81,8 +92,8 @@ const LoginForm: React.FC = () => {

{successMessage && ( -
- {successMessage} +
+ {t(successMessage) || successMessage}
)} @@ -96,7 +107,7 @@ const LoginForm: React.FC = () => { autoComplete="email" placeholder={t('login-email')} label={t('login-email')} - className={cn('rounded-t-md')} + className={cn('rounded-t-md bg-white/5 border-white/10 text-white')} />
@@ -108,11 +119,11 @@ const LoginForm: React.FC = () => { autoComplete="current-password" placeholder={t('login-password')} label={t('login-password')} - className={cn('rounded-b-md pr-10')} + className={cn('rounded-b-md pr-10 bg-white/5 border-white/10 text-white')} />
+
+ + {t('forgotPassword')} + +
+
diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index 1017a6a..76eb1ac 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -1,3 +1,4 @@ +import { authService } from '../../services/auth.service'; import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -9,6 +10,7 @@ import { SettingsMenu } from '../layout/SettingsMenu'; import { registerSchema, type RegisterFormData } from '../../schemas/auth.schema'; import { FormInput, FormError } from '../ui/form'; import { cn } from '../../utils/cn'; +import axios from 'axios'; const RegisterForm: React.FC = () => { const [showPassword, setShowPassword] = useState(false); @@ -73,29 +75,31 @@ const RegisterForm: React.FC = () => { ); const onSubmit = async (data: RegisterFormData) => { + setIsLoading(true); + try { - setIsLoading(true); - const success = await registerUser(data.name, data.email, data.password); - if (success.user) { - setIsLoading(false); - navigate('/login', { - state: { - message: t('registration_success'), - }, - }); - } + //verify your authService.ts has the correct baseURL + await authService.register({ + email: data.email, + password: data.password, + name: data.name, + }); + + console.log('Registration successful'); + navigate('/login', { + state: { + message: 'Registration successful! Please check your email to verify your account.', + }, + }); } catch (error: any) { - setIsLoading(false); + // Extract the specific error message from the backend (e.g., "Email already registered") + const errorMessage = + error.response?.data?.detail || + (error.code === 'ERR_NETWORK' ? 'Cannot connect to server' : t('register-error-generic')); - if (error.response) { - setFormError('root', { - message: error.response.data?.error.message || 'Server returned an error', - }); - } else if (error.request) { - setFormError('root', { message: 'No response from server. It might be offline.' }); - } else { - setFormError('root', { message: error.message || 'Network Error' }); - } + setFormError('root', { message: errorMessage }); + } finally { + setIsLoading(false); } }; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index cde2d5a..07053b7 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { User, AuthContextType, AuthResponse } from '../types/auth'; +import { User, AuthContextType } from '../types/auth'; import { authService } from '../services/auth.service'; const AuthContext = createContext(undefined); @@ -11,65 +11,49 @@ interface AuthProviderProps { export const AuthProvider: React.FC = ({ children }) => { const [user, setUser] = useState(null); const [token, setToken] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(true); // Always start as true to prevent premature redirects useEffect(() => { - const storedToken = localStorage.getItem('auth_token'); - if (storedToken) { - setToken(storedToken); - validateStoredToken(); - } else { - setIsLoading(false); - } - }, []); - - const validateStoredToken = async () => { - try { - const response = await authService.validateToken(); - - if (response.token) { - localStorage.setItem('auth_token', response.token); - setToken(response.token); + const initializeAuth = async () => { + const storedToken = localStorage.getItem('auth_token'); + + if (storedToken) { + setToken(storedToken); + try { + // Validate the token and fetch user data immediately + const response = await authService.validateToken(); + const userData = response.user || response; + setUser(userData); + } catch (error) { + // If validation fails, clear the stale data + localStorage.removeItem('auth_token'); + setToken(null); + setUser(null); + } } - setUser(response.user || response); - } catch (error) { - localStorage.removeItem('auth_token'); - setToken(null); - setUser(null); - } finally { + // Stop loading ONLY after we've confirmed the token status setIsLoading(false); - } - }; - - const login = async (email: string, password: string): Promise => { - try { - setIsLoading(true); + }; - const res = await authService.login({ email, password }); + initializeAuth(); + }, []); - setUser(res.user); - setToken(res.token); - localStorage.setItem('auth_token', res.token); + const login = async (email: string, password: string) => { + const result = await authService.login({ email, password }); - return res; - } catch (error) { - throw error; - } finally { - setIsLoading(false); + if (result.token && result.user) { + localStorage.setItem('auth_token', result.token); + setToken(result.token); + setUser(result.user); + return result; } }; - const register = async (name: string, email: string, password: string): Promise => { + const register = async (name: string, email: string, password: string): Promise => { try { setIsLoading(true); - const res = await authService.register({ name, email, password }); - - setUser(res.user); - setToken(res.token); - localStorage.setItem('auth_token', res.token); - return res; } catch (error) { throw error; @@ -82,6 +66,7 @@ export const AuthProvider: React.FC = ({ children }) => { setUser(null); setToken(null); localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); }; const value: AuthContextType = { diff --git a/frontend/src/pages/ForgotPasswordRequest.tsx b/frontend/src/pages/ForgotPasswordRequest.tsx new file mode 100644 index 0000000..bb067c4 --- /dev/null +++ b/frontend/src/pages/ForgotPasswordRequest.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { authService } from '../services/auth.service'; +import { Link } from 'react-router-dom'; + +export const ForgotPasswordRequest = () => { + const [email, setEmail] = useState(''); + const [sent, setSent] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + await authService.forgotPassword(email); + setSent(true); + } catch { + // Do not reveal whether email exists + setSent(true); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

Reset Password

+

+ Enter your email and we’ll send you a reset link. +

+
+ + {!sent ? ( + +
+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full rounded-md px-3 py-2 bg-white/5 border border-white/10 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + {error &&

{error}

} + + + +
+ Remembered your password? + + Back to Login + +
+ + ) : ( +
+
+ If the email address is registered, a password reset link will be delivered to your + inbox. +
+ + + Back to Login + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..e9e3434 --- /dev/null +++ b/frontend/src/pages/ResetPassword.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { EyeIcon, EyeSlashIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { authService } from '../services/auth.service'; +import { FormInput, FormError } from '../components/ui/form'; +import { cn } from '../utils/cn'; + +type ResetFormData = { + password: string; + confirmPassword: string; +}; + +export const ResetPassword: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [success, setSuccess] = useState(''); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + setError, + } = useForm({ + mode: 'onBlur', + }); + + const password = watch('password', ''); + const confirmPassword = watch('confirmPassword', ''); + + const passwordValidation = { + minLength: password.length >= 8, + hasUppercase: /[A-Z]/.test(password), + hasLowercase: /[a-z]/.test(password), + hasNumber: /[0-9]/.test(password), + hasSpecialChar: /[!@#$%^&*(),.?":{}|<>]/.test(password), + matches: password === confirmPassword && confirmPassword.length > 0, + }; + + const getPasswordStrength = () => { + const validCount = Object.values(passwordValidation).filter(Boolean).length - 1; + if (validCount <= 2) return { label: 'Weak', color: 'bg-red-500', width: '33%' }; + if (validCount <= 4) return { label: 'Medium', color: 'bg-yellow-500', width: '66%' }; + return { label: 'Strong', color: 'bg-green-500', width: '100%' }; + }; + + const strength = password.length > 0 ? getPasswordStrength() : null; + + const ValidationItem = ({ isValid, text }: { isValid: boolean; text: string }) => ( +
+ {isValid ? ( + + ) : ( + + )} + {text} +
+ ); + + const onSubmit = async (data: ResetFormData) => { + if (!token) { + setError('root', { message: 'Invalid or expired reset link.' }); + return; + } + + if (data.password !== data.confirmPassword) { + setError('confirmPassword', { message: 'Passwords do not match.' }); + return; + } + + setIsLoading(true); + try { + await authService.resetPassword(token, data.password); + setSuccess('Password updated successfully. Redirecting to login...'); + setTimeout(() => navigate('/login'), 3000); + } catch (err: any) { + setError('root', { + message: err.response?.data?.detail || 'Failed to reset password.', + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ Create New Password +

+

+ Enter and confirm your new password. +

+
+ +
+
+ {/* Password */} +
+ + +
+ + {password.length > 0 && ( +
+
+ Password strength + {strength?.label} +
+
+
+
+ +
+ + + + + +
+
+ )} + + {/* Confirm */} +
+ + +
+
+ + + + + + + {success &&

{success}

} +
+
+ ); +}; diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..9f8f393 --- /dev/null +++ b/frontend/src/pages/VerifyEmail.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams, Link } from 'react-router-dom'; +import axios from 'axios'; + +export const VerifyEmail = () => { + const [searchParams] = useSearchParams(); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [message, setMessage] = useState(''); + const token = searchParams.get('token'); + + useEffect(() => { + const verify = async () => { + try { + const response = await axios.get(`http://localhost:8000/api/auth/verify-email`, { + params: { token }, + }); + + setStatus('success'); + setMessage(response.data.message); + } catch (err: any) { + setStatus('error'); + setMessage(err.response?.data?.detail || 'The link is invalid or has expired.'); + } + }; + + if (token) { + verify(); + } else { + setStatus('error'); + setMessage('No verification token found.'); + } + }, [token]); + + return ( +
+
+ {status === 'loading' && ( + <> +

Finalizing Account...

+

Creating your profile on Shrutik Voice Platform.

+ + )} + + {status === 'success' && ( + <> +
+

Account Created!

+

{message}

+ + Sign In to Dashboard + + + )} + + {status === 'error' && ( + <> +
+

Verification Failed

+

{message}

+ + Return to Registration + + + )} +
+
+ ); +}; diff --git a/frontend/src/schemas/script.schema.ts b/frontend/src/schemas/script.schema.ts index 8bcb540..1a79a8e 100644 --- a/frontend/src/schemas/script.schema.ts +++ b/frontend/src/schemas/script.schema.ts @@ -15,7 +15,7 @@ const durationCategorySchema = z.custom( export const scriptSchema = z.object({ text: z.string().min(1, 'Script text is required').trim(), duration_category: durationCategorySchema, - language_id: z.number().int().positive().default(1), + language_id: z.coerce.number().int().positive().default(1), meta_data: z.record(z.string(), z.any()).default({}), }); diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index 9dbfa02..7712c39 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -50,4 +50,8 @@ export const adminService = { async getUsageAnalytics(days = 30): Promise { return apiClient.get(`/admin/analytics/usage?days=${days}`); }, + + async getLanguages(): Promise> { + return apiClient.get('/admin/languages'); + }, }; diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index b9df534..bc5836b 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -12,7 +12,7 @@ class ApiClient { }, }); - // Request interceptor to add auth token + // Request interceptor: Attach token to every request this.api.interceptors.request.use(config => { const token = localStorage.getItem('auth_token'); if (token) { @@ -21,10 +21,15 @@ class ApiClient { return config; }); - // Response interceptor for error handling + // Response interceptor: Global error handling this.api.interceptors.response.use( (response: AxiosResponse) => response, error => { + if (axios.isCancel(error)) { + return Promise.reject(error); + } + + // Only redirect on a 401 (Unauthorized) from the server if (error.response?.status === 401) { const hasToken = localStorage.getItem('auth_token'); const isLoginPage = window.location.pathname === '/login'; @@ -62,17 +67,13 @@ class ApiClient { async postFormData(url: string, formData: FormData): Promise { const response = await this.api.post(url, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; } async getBlob(url: string): Promise { - const response = await this.api.get(url, { - responseType: 'blob', - }); + const response = await this.api.get(url, { responseType: 'blob' }); return response.data; } } diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index e2fc291..535bdf0 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,51 +1,66 @@ import { apiClient } from './apiClient'; import { AuthResponse, LoginCredentials } from '../types/auth'; +import { CurrentUserStats } from '../types/api'; export const authService = { async login(credentials: LoginCredentials): Promise { - const loginResponse = await apiClient.post<{ access_token: string; token_type: string }>( - '/auth/login', - credentials + const loginData = await apiClient.post<{ access_token: string; token_type: string }>( + 'auth/login', + { email: credentials.email, password: credentials.password } ); - const token = loginResponse.access_token; + const accessToken = loginData.access_token; - // Temporarily set token to get user info - const originalAuth = apiClient.api.defaults.headers.Authorization; - apiClient.api.defaults.headers.Authorization = `Bearer ${token}`; + // Fetch user data with manual header to ensure success even if interceptor hasn't updated + const userResponse = await apiClient.api.get('auth/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); - try { - const userResponse = await apiClient.get('/auth/me'); + const userData = userResponse.data; - return { - user: userResponse, - token: token, - }; - } finally { - if (originalAuth !== undefined) { - apiClient.api.defaults.headers.Authorization = originalAuth; - } - } + // Save token after successful fetch + localStorage.setItem('auth_token', accessToken); + + return { + user: userData.user || userData, + token: accessToken, + }; }, - async register(userData: { - name: string; - email: string; - password: string; - }): Promise { - await apiClient.post('/auth/register', userData); + async forgotPassword(email: string): Promise { + const url = `auth/password-reset/request?email=${encodeURIComponent(email)}`; + await apiClient.post(url, {}); + }, - return await this.login({ - email: userData.email, - password: userData.password, + async resetPassword(token: string, newPassword: string): Promise { + await apiClient.api.post('auth/password-reset/confirm', null, { + params: { + token: token, + new_password: newPassword, + }, }); }, - async validateToken(): Promise { - return apiClient.get('/auth/me'); + async getCurrentUserStats(): Promise { + const token = localStorage.getItem('auth_token'); + try { + const response = await apiClient.api.get('auth/me/stats', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + return response.data; + } catch (error: any) { + if (error.response?.status === 404) { + return { recordings_count: 0, transcriptions_count: 0 } as any; + } + throw error; + } }, - async getCurrentUserStats(): Promise { - return apiClient.get('/auth/me/stats'); + async register(userData: { name: string; email: string; password: string }): Promise { + await apiClient.post('auth/register', userData); + }, + + async validateToken(): Promise { + return apiClient.get('auth/me'); }, }; diff --git a/scripts/init-db.py b/scripts/init-db.py index b6a0914..937562c 100755 --- a/scripts/init-db.py +++ b/scripts/init-db.py @@ -127,8 +127,8 @@ def run_alembic_migrations(): return False -def create_default_language(): - """Create default Bengali language if it doesn't exist.""" +def create_default_languages(): + """Create default Bengali and English languages if they don't exist.""" try: from app.models.language import Language @@ -144,13 +144,25 @@ def create_default_language(): logger.info("✅ Default Bengali language created") else: logger.info("✅ Bengali language already exists") + + # Check if English language exists + english = db.query(Language).filter(Language.code == "en").first() + if not english: + logger.info("🔄 Creating default English language...") + english = Language(code="en", name="English") + db.add(english) + db.commit() + logger.info("✅ Default English language created") + else: + logger.info("✅ English language already exists") + return True finally: db.close() except Exception as e: - logger.error(f"❌ Error creating default language: {e}") + logger.error(f"❌ Error creating default languages: {e}") return False @@ -184,8 +196,8 @@ def main(): logger.info("⏭️ Skipping Alembic migrations (tables already exist)") # Step 4: Create default data - if not create_default_language(): - logger.warning("⚠️ Could not create default language, but continuing...") + if not create_default_languages(): + logger.warning("⚠️ Could not create default languages, but continuing...") # Step 5: Final verification if check_tables_exist(): diff --git a/scripts/simple-init.py b/scripts/simple-init.py index 716a19e..1119d5b 100755 --- a/scripts/simple-init.py +++ b/scripts/simple-init.py @@ -27,8 +27,8 @@ def simple_init(): Base.metadata.create_all(bind=engine) print("✅ Database tables created") - # Create default language - print("🔄 Creating default Bengali language...") + # Create default languages + print("🔄 Creating default languages...") db = SessionLocal() try: # Check if Bengali language exists @@ -40,6 +40,16 @@ def simple_init(): print("✅ Default Bengali language created") else: print("✅ Bengali language already exists") + + # Check if English language exists + english = db.query(Language).filter(Language.code == "en").first() + if not english: + english = Language(code="en", name="English") + db.add(english) + db.commit() + print("✅ Default English language created") + else: + print("✅ English language already exists") finally: db.close() From faba4356a94ae9e8a10281c5252a44ccc04bc361 Mon Sep 17 00:00:00 2001 From: tanvrtian Date: Wed, 21 Jan 2026 01:22:26 +0600 Subject: [PATCH 2/3] fix: safe migration to add is_verified column only --- .../cd84ef65d459_add_is_verified_to_users.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 alembic/versions/cd84ef65d459_add_is_verified_to_users.py diff --git a/alembic/versions/cd84ef65d459_add_is_verified_to_users.py b/alembic/versions/cd84ef65d459_add_is_verified_to_users.py new file mode 100644 index 0000000..5060d6f --- /dev/null +++ b/alembic/versions/cd84ef65d459_add_is_verified_to_users.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") From 3898f0615ffd3185c30ea595480d7675a49d4817 Mon Sep 17 00:00:00 2001 From: tanvrtian Date: Wed, 21 Jan 2026 01:39:29 +0600 Subject: [PATCH 3/3] chore: rename migration to follow naming convention --- ...add_is_verified_to_users.py => 0006_add_is_verified_column.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename alembic/versions/{cd84ef65d459_add_is_verified_to_users.py => 0006_add_is_verified_column.py} (100%) diff --git a/alembic/versions/cd84ef65d459_add_is_verified_to_users.py b/alembic/versions/0006_add_is_verified_column.py similarity index 100% rename from alembic/versions/cd84ef65d459_add_is_verified_to_users.py rename to alembic/versions/0006_add_is_verified_column.py