feat: implement customer authentication with JWT tokens

Implement secure customer authentication system with dedicated JWT tokens,
separate from admin/vendor authentication.

Backend Changes:
- Add customer JWT token support in deps.py
  - New get_current_customer_from_cookie_or_header dependency
  - Validates customer-specific tokens with type checking
  - Returns Customer object instead of User for shop routes
- Extend AuthService with customer token support
  - Add verify_password() method
  - Add create_access_token_with_data() for custom token payloads
- Update CustomerService authentication
  - Generate customer-specific JWT tokens with type="customer"
  - Use vendor-scoped customer lookup
- Enhance exception handler
  - Sanitize validation errors to prevent password leaks in logs
  - Fix shop login redirect to support multi-access routing
- Improve vendor context detection from Referer header
  - Consistent "path" detection method for cookie path logic

Schema Changes:
- Rename UserLogin.username to email_or_username for flexibility
- Update field validators accordingly

API Changes:
- Update admin/vendor auth endpoints to use email_or_username
- Customer auth already uses email field correctly

Route Changes:
- Update shop account routes to use Customer dependency
- Add /account redirect (without trailing slash)
- Change parameter names from current_user to current_customer

Frontend Changes:
- Update login forms to use email_or_username in API calls
- Change button text from "Log in" to "Sign in" for consistency
- Improve loading spinner layout with flexbox

Security Improvements:
- Customer tokens scoped to vendor_id
- Token type validation prevents cross-context token usage
- Password inputs redacted from validation error logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 21:08:49 +01:00
parent 1f2ccb4668
commit 6735d99df2
13 changed files with 219 additions and 81 deletions

View File

@@ -315,9 +315,9 @@ def get_current_customer_from_cookie_or_header(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
customer_token: Optional[str] = Cookie(None), customer_token: Optional[str] = Cookie(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ):
""" """
Get current customer user from customer_token cookie or Authorization header. Get current customer from customer_token cookie or Authorization header.
Used for shop account HTML pages (/shop/account/*) that need cookie-based auth. Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
Note: Public shop pages (/shop/products, etc.) don't use this dependency. Note: Public shop pages (/shop/products, etc.) don't use this dependency.
@@ -333,12 +333,15 @@ def get_current_customer_from_cookie_or_header(
db: Database session db: Database session
Returns: Returns:
User: Authenticated customer user Customer: Authenticated customer object
Raises: Raises:
InvalidTokenException: If no token or invalid token InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not customer (admin/vendor blocked)
""" """
from models.database.customer import Customer
from jose import jwt, JWTError
from datetime import datetime, timezone
token, source = _get_token_from_request( token, source = _get_token_from_request(
credentials, credentials,
customer_token, customer_token,
@@ -350,35 +353,50 @@ def get_current_customer_from_cookie_or_header(
logger.warning(f"Customer auth failed: No token for {request.url.path}") logger.warning(f"Customer auth failed: No token for {request.url.path}")
raise InvalidTokenException("Customer authentication required") raise InvalidTokenException("Customer authentication required")
# Validate token and get user # Decode and validate customer JWT token
user = _validate_user_token(token, db) try:
payload = jwt.decode(
# CRITICAL: Block admins from customer routes token,
if user.role == "admin": auth_manager.secret_key,
logger.warning( algorithms=[auth_manager.algorithm]
f"Admin user {user.username} attempted shop account: {request.url.path}"
)
raise InsufficientPermissionsException(
"Customer access only - admins cannot use shop"
) )
# CRITICAL: Block vendors from customer routes # Verify this is a customer token
if user.role == "vendor": token_type = payload.get("type")
logger.warning( if token_type != "customer":
f"Vendor user {user.username} attempted shop account: {request.url.path}" logger.warning(f"Invalid token type for customer route: {token_type}")
) raise InvalidTokenException("Customer authentication required")
raise InsufficientPermissionsException(
"Customer access only - vendors cannot use shop"
)
# Verify user is customer # Get customer ID from token
if user.role != "customer": customer_id: str = payload.get("sub")
logger.warning( if customer_id is None:
f"Non-customer user {user.username} attempted shop account: {request.url.path}" logger.warning("Token missing 'sub' (customer_id)")
) raise InvalidTokenException("Invalid token")
raise InsufficientPermissionsException("Customer privileges required")
return user # Verify token hasn't expired
exp = payload.get("exp")
if exp and datetime.fromtimestamp(exp, tz=timezone.utc) < datetime.now(timezone.utc):
logger.warning(f"Expired customer token for customer_id={customer_id}")
raise InvalidTokenException("Token has expired")
except JWTError as e:
logger.warning(f"JWT decode error: {str(e)}")
raise InvalidTokenException("Could not validate credentials")
# Load customer from database
customer = db.query(Customer).filter(Customer.id == int(customer_id)).first()
if not customer:
logger.warning(f"Customer not found: {customer_id}")
raise InvalidTokenException("Customer not found")
if not customer.is_active:
logger.warning(f"Inactive customer attempted access: {customer.email}")
raise InvalidTokenException("Customer account is inactive")
logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})")
return customer
def get_current_customer_api( def get_current_customer_api(

View File

@@ -49,7 +49,7 @@ def admin_login(
# Verify user is admin # Verify user is admin
if login_result["user"].role != "admin": if login_result["user"].role != "admin":
logger.warning(f"Non-admin user attempted admin login: {user_credentials.username}") logger.warning(f"Non-admin user attempted admin login: {user_credentials.email_or_username}")
raise InvalidCredentialsException("Admin access required") raise InvalidCredentialsException("Admin access required")
logger.info(f"Admin login successful: {login_result['user'].username}") logger.info(f"Admin login successful: {login_result['user'].username}")

View File

@@ -20,14 +20,24 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.services.customer_service import customer_service from app.services.customer_service import customer_service
from models.schema.auth import LoginResponse, UserLogin from models.schema.auth import UserLogin
from models.schema.customer import CustomerRegister, CustomerResponse from models.schema.customer import CustomerRegister, CustomerResponse
from app.core.environment import should_use_secure_cookies from app.core.environment import should_use_secure_cookies
from pydantic import BaseModel
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Response model for customer login
class CustomerLoginResponse(BaseModel):
"""Customer login response with token and customer data."""
access_token: str
token_type: str
expires_in: int
user: CustomerResponse # Use CustomerResponse instead of UserResponse
@router.post("/auth/register", response_model=CustomerResponse) @router.post("/auth/register", response_model=CustomerResponse)
def register_customer( def register_customer(
request: Request, request: Request,
@@ -85,7 +95,7 @@ def register_customer(
return CustomerResponse.model_validate(customer) return CustomerResponse.model_validate(customer)
@router.post("/auth/login", response_model=LoginResponse) @router.post("/auth/login", response_model=CustomerLoginResponse)
def customer_login( def customer_login(
request: Request, request: Request,
user_credentials: UserLogin, user_credentials: UserLogin,
@@ -144,8 +154,18 @@ def customer_login(
} }
) )
# Calculate cookie path based on vendor access method
vendor_context = getattr(request.state, 'vendor_context', None)
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
cookie_path = "/shop" # Default for domain/subdomain access
if access_method == "path":
# For path-based access like /vendors/wizamart/shop
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
# Set HTTP-only cookie for browser navigation # Set HTTP-only cookie for browser navigation
# CRITICAL: path=/shop restricts cookie to shop routes only # Cookie path matches the vendor's shop routes
response.set_cookie( response.set_cookie(
key="customer_token", key="customer_token",
value=login_result["token_data"]["access_token"], value=login_result["token_data"]["access_token"],
@@ -153,24 +173,25 @@ def customer_login(
secure=should_use_secure_cookies(), # HTTPS only in production/staging secure=should_use_secure_cookies(), # HTTPS only in production/staging
samesite="lax", # CSRF protection samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/shop", # RESTRICTED TO SHOP ROUTES ONLY path=cookie_path, # Matches vendor's shop routes
) )
logger.debug( logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry " f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/shop, httponly=True, secure={should_use_secure_cookies()})", f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
extra={ extra={
"expires_in": login_result['token_data']['expires_in'], "expires_in": login_result['token_data']['expires_in'],
"secure": should_use_secure_cookies(), "secure": should_use_secure_cookies(),
"cookie_path": cookie_path,
} }
) )
# Return full login response # Return full login response
return LoginResponse( return CustomerLoginResponse(
access_token=login_result["token_data"]["access_token"], access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"], token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"], expires_in=login_result["token_data"]["expires_in"],
user=login_result["customer"], # Return customer as user user=CustomerResponse.model_validate(login_result["customer"]),
) )
@@ -197,13 +218,23 @@ def customer_logout(
} }
) )
# Calculate cookie path based on vendor access method (must match login)
vendor_context = getattr(request.state, 'vendor_context', None)
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
cookie_path = "/shop" # Default for domain/subdomain access
if access_method == "path" and vendor:
# For path-based access like /vendors/wizamart/shop
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
# Clear the cookie (must match path used when setting) # Clear the cookie (must match path used when setting)
response.delete_cookie( response.delete_cookie(
key="customer_token", key="customer_token",
path="/shop", path=cookie_path,
) )
logger.debug("Deleted customer_token cookie") logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}

View File

@@ -135,10 +135,19 @@ def setup_exception_handlers(app):
async def validation_exception_handler(request: Request, exc: RequestValidationError): async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle Pydantic validation errors with consistent format.""" """Handle Pydantic validation errors with consistent format."""
# Sanitize errors to remove sensitive data from logs
sanitized_errors = []
for error in exc.errors():
sanitized_error = error.copy()
# Remove 'input' field which may contain passwords
if 'input' in sanitized_error:
sanitized_error['input'] = '<redacted>'
sanitized_errors.append(sanitized_error)
logger.error( logger.error(
f"Validation error in {request.method} {request.url}: {exc.errors()}", f"Validation error in {request.method} {request.url}: {len(sanitized_errors)} validation error(s)",
extra={ extra={
"validation_errors": exc.errors(), "validation_errors": sanitized_errors,
"url": str(request.url), "url": str(request.url),
"method": request.method, "method": request.method,
"exception_type": "RequestValidationError", "exception_type": "RequestValidationError",
@@ -357,6 +366,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
Redirect to appropriate login page based on request context. Redirect to appropriate login page based on request context.
Uses context detection to determine admin vs vendor vs shop login. Uses context detection to determine admin vs vendor vs shop login.
Properly handles multi-access routing (domain, subdomain, path-based).
""" """
context_type = get_request_context(request) context_type = get_request_context(request)
@@ -368,8 +378,19 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
return RedirectResponse(url="/vendor/login", status_code=302) return RedirectResponse(url="/vendor/login", status_code=302)
elif context_type == RequestContext.SHOP: elif context_type == RequestContext.SHOP:
# For shop context, redirect to shop login (customer login) # For shop context, redirect to shop login (customer login)
logger.debug("Redirecting to /shop/login") # Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
return RedirectResponse(url="/shop/login", status_code=302) vendor = getattr(request.state, 'vendor', None)
vendor_context = getattr(request.state, 'vendor_context', None)
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
base_url = "/"
if access_method == "path" and vendor:
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
base_url = f"{full_prefix}{vendor.subdomain}/"
login_url = f"{base_url}shop/account/login"
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
else: else:
# Fallback to root for unknown contexts # Fallback to root for unknown contexts
logger.debug("Unknown context, redirecting to /") logger.debug("Unknown context, redirecting to /")

View File

@@ -38,7 +38,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db from app.api.deps import get_current_customer_from_cookie_or_header, get_db
from app.services.content_page_service import content_page_service from app.services.content_page_service import content_page_service
from models.database.user import User from models.database.customer import Customer
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -370,10 +370,11 @@ async def shop_forgot_password_page(request: Request):
# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES # CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
# ============================================================================ # ============================================================================
@router.get("/account", response_class=RedirectResponse, include_in_schema=False)
@router.get("/account/", response_class=RedirectResponse, include_in_schema=False) @router.get("/account/", response_class=RedirectResponse, include_in_schema=False)
async def shop_account_root(request: Request): async def shop_account_root(request: Request):
""" """
Redirect /shop/account/ to dashboard. Redirect /shop/account or /shop/account/ to dashboard.
""" """
logger.debug( logger.debug(
f"[SHOP_HANDLER] shop_products_page REACHED", f"[SHOP_HANDLER] shop_products_page REACHED",
@@ -400,7 +401,7 @@ async def shop_account_root(request: Request):
@router.get("/account/dashboard", response_class=HTMLResponse, include_in_schema=False) @router.get("/account/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def shop_account_dashboard_page( async def shop_account_dashboard_page(
request: Request, request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -419,14 +420,14 @@ async def shop_account_dashboard_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/dashboard.html", "shop/account/dashboard.html",
get_shop_context(request, user=current_user) get_shop_context(request, user=current_customer)
) )
@router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False) @router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False)
async def shop_orders_page( async def shop_orders_page(
request: Request, request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -445,7 +446,7 @@ async def shop_orders_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/orders.html", "shop/account/orders.html",
get_shop_context(request, user=current_user) get_shop_context(request, user=current_customer)
) )
@@ -453,7 +454,7 @@ async def shop_orders_page(
async def shop_order_detail_page( async def shop_order_detail_page(
request: Request, request: Request,
order_id: int = Path(..., description="Order ID"), order_id: int = Path(..., description="Order ID"),
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -472,14 +473,14 @@ async def shop_order_detail_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/order-detail.html", "shop/account/order-detail.html",
get_shop_context(request, user=current_user, order_id=order_id) get_shop_context(request, user=current_customer, order_id=order_id)
) )
@router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False) @router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False)
async def shop_profile_page( async def shop_profile_page(
request: Request, request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -498,14 +499,14 @@ async def shop_profile_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/profile.html", "shop/account/profile.html",
get_shop_context(request, user=current_user) get_shop_context(request, user=current_customer)
) )
@router.get("/account/addresses", response_class=HTMLResponse, include_in_schema=False) @router.get("/account/addresses", response_class=HTMLResponse, include_in_schema=False)
async def shop_addresses_page( async def shop_addresses_page(
request: Request, request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -524,14 +525,14 @@ async def shop_addresses_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/addresses.html", "shop/account/addresses.html",
get_shop_context(request, user=current_user) get_shop_context(request, user=current_customer)
) )
@router.get("/account/wishlist", response_class=HTMLResponse, include_in_schema=False) @router.get("/account/wishlist", response_class=HTMLResponse, include_in_schema=False)
async def shop_wishlist_page( async def shop_wishlist_page(
request: Request, request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -550,14 +551,14 @@ async def shop_wishlist_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/wishlist.html", "shop/account/wishlist.html",
get_shop_context(request, user=current_user) get_shop_context(request, user=current_customer)
) )
@router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False) @router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False)
async def shop_settings_page( async def shop_settings_page(
request: Request, request: Request,
current_user: User = Depends(get_current_customer_from_cookie_or_header), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -576,7 +577,7 @@ async def shop_settings_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"shop/account/settings.html", "shop/account/settings.html",
get_shop_context(request, user=current_user) get_shop_context(request, user=current_customer)
) )

View File

@@ -98,7 +98,7 @@ class AuthService:
""" """
try: try:
user = self.auth_manager.authenticate_user( user = self.auth_manager.authenticate_user(
db, user_credentials.username, user_credentials.password db, user_credentials.email_or_username, user_credentials.password
) )
if not user: if not user:
raise InvalidCredentialsException("Incorrect username or password") raise InvalidCredentialsException("Incorrect username or password")
@@ -161,6 +161,52 @@ class AuthService:
logger.error(f"Error hashing password: {str(e)}") logger.error(f"Error hashing password: {str(e)}")
raise ValidationException("Failed to hash password") raise ValidationException("Failed to hash password")
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash."""
try:
return self.auth_manager.verify_password(plain_password, hashed_password)
except Exception as e:
logger.error(f"Error verifying password: {str(e)}")
return False
def create_access_token_with_data(self, data: dict) -> dict:
"""
Create JWT token with custom data payload.
Useful for non-User entities like customers that need tokens.
Args:
data: Dictionary containing token payload data (must include 'sub')
Returns:
Dictionary with access_token, token_type, and expires_in
"""
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
try:
expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(timezone.utc) + expires_delta
# Build payload with provided data
payload = {
**data,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
return {
"access_token": token,
"token_type": "bearer",
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
}
except Exception as e:
logger.error(f"Error creating access token with data: {str(e)}")
raise ValidationException("Failed to create access token")
# Private helper methods # Private helper methods
def _email_exists(self, db: Session, email: str) -> bool: def _email_exists(self, db: Session, email: str) -> bool:
"""Check if email already exists.""" """Check if email already exists."""

View File

@@ -149,15 +149,15 @@ class CustomerService:
customer = db.query(Customer).filter( customer = db.query(Customer).filter(
and_( and_(
Customer.vendor_id == vendor_id, Customer.vendor_id == vendor_id,
Customer.email == credentials.username.lower() Customer.email == credentials.email_or_username.lower()
) )
).first() ).first()
if not customer: if not customer:
raise InvalidCustomerCredentialsException() raise InvalidCustomerCredentialsException()
# Verify password # Verify password using auth_manager directly
if not self.auth_service.verify_password( if not self.auth_service.auth_manager.verify_password(
credentials.password, credentials.password,
customer.hashed_password customer.hashed_password
): ):
@@ -168,14 +168,30 @@ class CustomerService:
raise CustomerNotActiveException(customer.email) raise CustomerNotActiveException(customer.email)
# Generate JWT token with customer context # Generate JWT token with customer context
token_data = self.auth_service.create_access_token( # Use auth_manager directly since Customer is not a User model
data={ from datetime import datetime, timedelta, timezone
"sub": str(customer.id), from jose import jwt
"email": customer.email,
"vendor_id": vendor_id, auth_manager = self.auth_service.auth_manager
"type": "customer" expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
} expire = datetime.now(timezone.utc) + expires_delta
)
payload = {
"sub": str(customer.id),
"email": customer.email,
"vendor_id": vendor_id,
"type": "customer",
"exp": expire,
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, auth_manager.secret_key, algorithm=auth_manager.algorithm)
token_data = {
"access_token": token,
"token_type": "bearer",
"expires_in": auth_manager.token_expire_minutes * 60,
}
logger.info( logger.info(
f"Customer login successful: {customer.email} " f"Customer login successful: {customer.email} "

View File

@@ -69,8 +69,8 @@
<button type="submit" :disabled="loading" <button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"> class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Log in</span> <span x-show="!loading">Sign in</span>
<span x-show="loading"> <span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>

View File

@@ -84,8 +84,8 @@
<button type="submit" :disabled="loading" <button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"> class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Log in</span> <span x-show="!loading">Sign in</span>
<span x-show="loading"> <span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>

View File

@@ -256,13 +256,18 @@ class VendorContextManager:
path_parts = referer_path[len(prefix):].split("/") path_parts = referer_path[len(prefix):].split("/")
if len(path_parts) >= 1 and path_parts[0]: if len(path_parts) >= 1 and path_parts[0]:
vendor_code = path_parts[0] vendor_code = path_parts[0]
prefix_len = len(prefix)
logger.debug( logger.debug(
f"[VENDOR] Extracted vendor from Referer path: {vendor_code}", f"[VENDOR] Extracted vendor from Referer path: {vendor_code}",
extra={"vendor_code": vendor_code, "method": "referer_path"} extra={"vendor_code": vendor_code, "method": "referer_path"}
) )
# Use "path" as detection_method to be consistent with direct path detection
# This allows cookie path logic to work the same way
return { return {
"subdomain": vendor_code, "subdomain": vendor_code,
"detection_method": "referer_path", "detection_method": "path", # Consistent with direct path detection
"path_prefix": referer_path[:prefix_len + len(vendor_code)], # /vendor/vendor1
"full_prefix": prefix, # /vendor/ or /vendors/
"host": referer_host, "host": referer_host,
"referer": referer, "referer": referer,
} }

View File

@@ -29,13 +29,13 @@ class UserRegister(BaseModel):
class UserLogin(BaseModel): class UserLogin(BaseModel):
username: str = Field(..., description="Username") email_or_username: str = Field(..., description="Username or email address")
password: str = Field(..., description="Password") password: str = Field(..., description="Password")
vendor_code: Optional[str] = Field(None, description="Optional vendor code for context") vendor_code: Optional[str] = Field(None, description="Optional vendor code for context")
@field_validator("username") @field_validator("email_or_username")
@classmethod @classmethod
def validate_username(cls, v): def validate_email_or_username(cls, v):
return v.strip() return v.strip()

View File

@@ -111,7 +111,7 @@ function adminLogin() {
const url = '/admin/auth/login'; const url = '/admin/auth/login';
const payload = { const payload = {
username: this.credentials.username.trim(), email_or_username: this.credentials.username.trim(),
password: this.credentials.password password: this.credentials.password
}; };

View File

@@ -90,7 +90,7 @@ function vendorLogin() {
const startTime = performance.now(); const startTime = performance.now();
const response = await apiClient.post('/vendor/auth/login', { const response = await apiClient.post('/vendor/auth/login', {
username: this.credentials.username, email_or_username: this.credentials.username,
password: this.credentials.password, password: this.credentials.password,
vendor_code: this.vendorCode vendor_code: this.vendorCode
}); });