refactor: migrate vendor auth, profile, team, dashboard, settings to modules

Tenancy module (identity & organizational hierarchy):
- vendor_auth.py: login, logout, /me endpoints
- vendor_profile.py: vendor profile get/update
- vendor_team.py: team management, roles, permissions, invitations

Core module (foundational non-domain features):
- vendor_dashboard.py: dashboard statistics
- vendor_settings.py: localization, business info, letzshop settings

All routes auto-discovered via is_self_contained=True.
Deleted 5 legacy files from app/api/v1/vendor/.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 15:09:41 +01:00
parent d747f9ebaa
commit da3f28849e
12 changed files with 105 additions and 52 deletions

View File

@@ -2,10 +2,21 @@
"""
Tenancy module API routes.
Includes:
Vendor routes:
- /info/{vendor_code} - Public vendor info lookup
- /auth/* - Vendor authentication (login, logout, /me)
- /profile/* - Vendor profile management
- /team/* - Team member management, roles, permissions
"""
from .vendor import vendor_router
from .vendor_auth import vendor_auth_router
from .vendor_profile import vendor_profile_router
from .vendor_team import vendor_team_router
__all__ = ["vendor_router"]
__all__ = [
"vendor_router",
"vendor_auth_router",
"vendor_profile_router",
"vendor_team_router",
]

View File

@@ -2,12 +2,13 @@
"""
Tenancy module vendor API routes.
Provides public vendor information lookup for:
- Vendor login pages to display branding
- Public vendor profile lookup
Aggregates all vendor tenancy routes:
- /info/{vendor_code} - Public vendor info lookup
- /auth/* - Vendor authentication (login, logout, /me)
- /profile/* - Vendor profile management
- /team/* - Team member management, roles, permissions
These endpoints do NOT require authentication - they provide
public information about vendors.
The tenancy module owns identity and organizational hierarchy.
"""
import logging
@@ -80,3 +81,17 @@ def get_vendor_info(
owner_email=vendor.company.owner.email,
owner_username=vendor.company.owner.username,
)
# ============================================================================
# Aggregate Sub-Routers
# ============================================================================
# Include all tenancy vendor routes (auth, profile, team)
from .vendor_auth import vendor_auth_router
from .vendor_profile import vendor_profile_router
from .vendor_team import vendor_team_router
vendor_router.include_router(vendor_auth_router, tags=["vendor-auth"])
vendor_router.include_router(vendor_profile_router, tags=["vendor-profile"])
vendor_router.include_router(vendor_team_router, tags=["vendor-team"])

View File

@@ -0,0 +1,196 @@
# app/modules/tenancy/routes/api/vendor_auth.py
"""
Vendor team authentication endpoints.
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/vendor (restricted to vendor routes only)
- Returns token in response for localStorage (API calls)
This prevents:
- Vendor cookies from being sent to admin routes
- Admin cookies from being sent to vendor routes
- Cross-context authentication confusion
"""
import logging
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service
from middleware.vendor_context import get_current_vendor
from models.schema.auth import UserContext
from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
vendor_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
# Response model for vendor login
class VendorLoginResponse(BaseModel):
access_token: str
token_type: str
expires_in: int
user: dict
vendor: dict
vendor_role: str
@vendor_auth_router.post("/login", response_model=VendorLoginResponse)
def vendor_login(
user_credentials: UserLogin,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""
Vendor team member login.
Authenticates users who are part of a vendor team.
Validates against vendor context if available.
Sets token in two places:
1. HTTP-only cookie with path=/vendor (for browser page navigation)
2. Response body (for localStorage and API calls)
Prevents admin users from logging into vendor portal.
"""
# Try to get vendor from middleware first
vendor = get_current_vendor(request)
# If no vendor from middleware, try to get from request body
if not vendor and hasattr(user_credentials, "vendor_code"):
vendor_code = getattr(user_credentials, "vendor_code", None)
if vendor_code:
vendor = auth_service.get_vendor_by_code(db, vendor_code)
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
user = login_result["user"]
# CRITICAL: Prevent admin users from using vendor login
if user.role == "admin":
logger.warning(f"Admin user attempted vendor login: {user.username}")
raise InvalidCredentialsException(
"Admins cannot access vendor portal. Please use admin portal."
)
# Determine vendor and role
vendor_role = "Member"
if vendor:
# Check if user has access to this vendor
has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
if has_access:
vendor_role = role
else:
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException("You do not have access to this vendor")
else:
# No vendor context - find which vendor this user belongs to
vendor, vendor_role = auth_service.find_user_vendor(user)
if not vendor:
raise InvalidCredentialsException("User is not associated with any vendor")
logger.info(
f"Vendor team login successful: {user.username} "
f"for vendor {vendor.vendor_code} as {vendor_role}"
)
# Create vendor-scoped access token with vendor information
token_data = auth_service.auth_manager.create_access_token(
user=user,
vendor_id=vendor.id,
vendor_code=vendor.vendor_code,
vendor_role=vendor_role,
)
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/vendor restricts cookie to vendor routes only
response.set_cookie(
key="vendor_token",
value=token_data["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=should_use_secure_cookies(), # HTTPS only in production/staging
samesite="lax", # CSRF protection
max_age=token_data["expires_in"], # Match JWT expiry
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
)
logger.debug(
f"Set vendor_token cookie with {token_data['expires_in']}s expiry "
f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})"
)
# Return full login response with vendor-scoped token
return VendorLoginResponse(
access_token=token_data["access_token"],
token_type=token_data["token_type"],
expires_in=token_data["expires_in"],
user={
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"is_active": user.is_active,
},
vendor={
"id": vendor.id,
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
"name": vendor.name,
"is_active": vendor.is_active,
"is_verified": vendor.is_verified,
},
vendor_role=vendor_role,
)
@vendor_auth_router.post("/logout", response_model=LogoutResponse)
def vendor_logout(response: Response):
"""
Vendor team member logout.
Clears the vendor_token cookie.
Client should also remove token from localStorage.
"""
logger.info("Vendor logout")
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="vendor_token",
path="/vendor",
)
logger.debug("Deleted vendor_token cookie")
return LogoutResponse(message="Logged out successfully")
@vendor_auth_router.get("/me", response_model=VendorUserResponse)
def get_current_vendor_user(
user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db)
):
"""
Get current authenticated vendor user.
This endpoint can be called to verify authentication and get user info.
Requires Authorization header (header-only authentication for API endpoints).
"""
return VendorUserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
)

View File

@@ -0,0 +1,44 @@
# app/modules/tenancy/routes/api/vendor_profile.py
"""
Vendor profile management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from models.schema.vendor import VendorResponse, VendorUpdate
vendor_profile_router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__)
@vendor_profile_router.get("", response_model=VendorResponse)
def get_vendor_profile(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get current vendor profile information."""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return vendor
@vendor_profile_router.put("", response_model=VendorResponse)
def update_vendor_profile(
vendor_update: VendorUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update vendor profile information."""
# Service handles permission checking and raises InsufficientPermissionsException if needed
return vendor_service.update_vendor(
db, current_user.token_vendor_id, vendor_update, current_user
)

View File

@@ -0,0 +1,485 @@
# app/modules/tenancy/routes/api/vendor_team.py
"""
Vendor team member management endpoints.
Implements complete team management with:
- Team member listing
- Invitation system
- Role management
- Permission checking
- RBAC integration
"""
import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import (
get_current_vendor_api,
get_user_permissions,
require_vendor_owner,
require_vendor_permission,
)
from app.core.database import get_db
from app.core.permissions import VendorPermissions
from app.services.vendor_team_service import vendor_team_service
from models.schema.auth import UserContext
from models.schema.team import (
BulkRemoveRequest,
BulkRemoveResponse,
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
RoleListResponse,
TeamMemberInvite,
TeamMemberListResponse,
TeamMemberResponse,
TeamMemberUpdate,
TeamStatistics,
UserPermissionsResponse,
)
vendor_team_router = APIRouter(prefix="/team")
logger = logging.getLogger(__name__)
# ============================================================================
# Team Member Routes
# ============================================================================
@vendor_team_router.get("/members", response_model=TeamMemberListResponse)
def list_team_members(
request: Request,
include_inactive: bool = False,
db: Session = Depends(get_db),
current_user: UserContext = Depends(
require_vendor_permission(VendorPermissions.TEAM_VIEW.value)
),
):
"""
Get all team members for current vendor.
**Required Permission:** `team.view`
**Query Parameters:**
- `include_inactive`: Include inactive team members (default: False)
**Returns:**
- List of team members with their roles and permissions
- Statistics (total, active, pending)
"""
vendor = request.state.vendor
members = vendor_team_service.get_team_members(
db=db, vendor=vendor, include_inactive=include_inactive
)
# Calculate statistics
total = len(members)
active = sum(1 for m in members if m["is_active"])
pending = sum(1 for m in members if m["invitation_pending"])
logger.info(
f"Listed {total} team members for vendor {vendor.vendor_code} "
f"(active: {active}, pending: {pending})"
)
return TeamMemberListResponse(
members=members, total=total, active_count=active, pending_invitations=pending
)
@vendor_team_router.post("/invite", response_model=InvitationResponse)
def invite_team_member(
invitation: TeamMemberInvite,
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_vendor_owner), # Owner only
):
"""
Invite a new team member to the vendor.
**Required:** Vendor owner role
**Process:**
1. Create user account (if doesn't exist)
2. Create VendorUser with invitation token
3. Send invitation email
**Request Body:**
- `email`: Email address of invitee
- `first_name`, `last_name`: Optional names
- `role_name`: Preset role (manager, staff, support, viewer, marketing)
- `role_id`: Use existing role (alternative to role_name)
- `custom_permissions`: Override role permissions (requires role_name)
**Returns:**
- Invitation details
- Confirmation of email sent
"""
vendor = request.state.vendor
# Determine role approach
if invitation.role_id:
# Use existing role by ID
result = vendor_team_service.invite_team_member(
db=db,
vendor=vendor,
inviter=current_user,
email=invitation.email,
role_id=invitation.role_id,
)
elif invitation.role_name:
# Use role name with optional custom permissions
result = vendor_team_service.invite_team_member(
db=db,
vendor=vendor,
inviter=current_user,
email=invitation.email,
role_name=invitation.role_name,
custom_permissions=invitation.custom_permissions,
)
else:
# Default to Staff role
result = vendor_team_service.invite_team_member(
db=db,
vendor=vendor,
inviter=current_user,
email=invitation.email,
role_name="staff",
)
db.commit()
logger.info(
f"Invitation sent: {invitation.email} to {vendor.vendor_code} "
f"by {current_user.username}"
)
return InvitationResponse(
message="Invitation sent successfully",
email=result["email"],
role=result["role"],
invitation_sent=True,
)
@vendor_team_router.post("/accept-invitation", response_model=InvitationAcceptResponse) # public
def accept_invitation(acceptance: InvitationAccept, db: Session = Depends(get_db)):
"""
Accept a team invitation and activate account.
**No authentication required** - uses invitation token.
**Request Body:**
- `invitation_token`: Token from invitation email
- `password`: New password (min 8 chars, must have upper, lower, digit)
- `first_name`, `last_name`: User's name
**Returns:**
- Confirmation message
- Vendor information
- User information
- Assigned role
"""
result = vendor_team_service.accept_invitation(
db=db,
invitation_token=acceptance.invitation_token,
password=acceptance.password,
first_name=acceptance.first_name,
last_name=acceptance.last_name,
)
db.commit()
logger.info(
f"Invitation accepted: {result['user'].email} "
f"for vendor {result['vendor'].vendor_code}"
)
return InvitationAcceptResponse(
message="Invitation accepted successfully. You can now login.",
vendor={
"id": result["vendor"].id,
"vendor_code": result["vendor"].vendor_code,
"name": result["vendor"].name,
"subdomain": result["vendor"].subdomain,
},
user={
"id": result["user"].id,
"email": result["user"].email,
"username": result["user"].username,
"full_name": result["user"].full_name,
},
role=result["role"],
)
@vendor_team_router.get("/members/{user_id}", response_model=TeamMemberResponse)
def get_team_member(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(
require_vendor_permission(VendorPermissions.TEAM_VIEW.value)
),
):
"""
Get details of a specific team member.
**Required Permission:** `team.view`
"""
vendor = request.state.vendor
members = vendor_team_service.get_team_members(
db=db, vendor=vendor, include_inactive=True
)
member = next((m for m in members if m["id"] == user_id), None)
if not member:
from app.exceptions import UserNotFoundException
raise UserNotFoundException(str(user_id))
return TeamMemberResponse(**member)
@vendor_team_router.put("/members/{user_id}", response_model=TeamMemberResponse)
def update_team_member(
user_id: int,
update_data: TeamMemberUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_vendor_owner), # Owner only
):
"""
Update a team member's role or status.
**Required:** Vendor owner role
**Cannot:**
- Change owner's role
- Remove owner
**Request Body:**
- `role_id`: New role ID (optional)
- `is_active`: Active status (optional)
"""
vendor = request.state.vendor
vendor_user = vendor_team_service.update_member_role(
db=db,
vendor=vendor,
user_id=user_id,
new_role_id=update_data.role_id,
is_active=update_data.is_active,
)
db.commit()
logger.info(
f"Team member updated: {user_id} in {vendor.vendor_code} "
f"by {current_user.username}"
)
# Return updated member details
members = vendor_team_service.get_team_members(db, vendor, include_inactive=True)
member = next((m for m in members if m["id"] == user_id), None)
return TeamMemberResponse(**member)
@vendor_team_router.delete("/members/{user_id}")
def remove_team_member(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_vendor_owner), # Owner only
):
"""
Remove a team member from the vendor.
**Required:** Vendor owner role
**Cannot remove:**
- Vendor owner
**Action:**
- Soft delete (sets is_active = False)
- Member can be re-invited later
"""
vendor = request.state.vendor
vendor_team_service.remove_team_member(db=db, vendor=vendor, user_id=user_id)
db.commit()
logger.info(
f"Team member removed: {user_id} from {vendor.vendor_code} "
f"by {current_user.username}"
)
return {"message": "Team member removed successfully", "user_id": user_id}
@vendor_team_router.post("/members/bulk-remove", response_model=BulkRemoveResponse)
def bulk_remove_team_members(
bulk_remove: BulkRemoveRequest,
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(require_vendor_owner),
):
"""
Remove multiple team members at once.
**Required:** Vendor owner role
"""
vendor = request.state.vendor
success_count = 0
failed_count = 0
errors = []
for user_id in bulk_remove.user_ids:
try:
vendor_team_service.remove_team_member(
db=db, vendor=vendor, user_id=user_id
)
success_count += 1
except Exception as e:
failed_count += 1
errors.append({"user_id": user_id, "error": str(e)})
db.commit()
logger.info(
f"Bulk remove completed: {success_count} removed, {failed_count} failed "
f"in {vendor.vendor_code}"
)
return BulkRemoveResponse(
success_count=success_count, failed_count=failed_count, errors=errors
)
# ============================================================================
# Role Management Routes
# ============================================================================
@vendor_team_router.get("/roles", response_model=RoleListResponse)
def list_roles(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(
require_vendor_permission(VendorPermissions.TEAM_VIEW.value)
),
):
"""
Get all available roles for the vendor.
**Required Permission:** `team.view`
**Returns:**
- List of roles with permissions
- Includes both preset and custom roles
"""
vendor = request.state.vendor
roles = vendor_team_service.get_vendor_roles(db=db, vendor_id=vendor.id)
db.commit() # Commit in case default roles were created
return RoleListResponse(roles=roles, total=len(roles))
# ============================================================================
# Permission Routes
# ============================================================================
@vendor_team_router.get("/me/permissions", response_model=UserPermissionsResponse)
def get_my_permissions(
request: Request,
permissions: list[str] = Depends(get_user_permissions),
current_user: UserContext = Depends(get_current_vendor_api),
):
"""
Get current user's permissions in this vendor.
**Use this endpoint to:**
- Determine what UI elements to show/hide
- Check permissions before making API calls
- Display user's role and capabilities
**Returns:**
- Complete list of permissions
- Whether user is owner
- Role name (if team member)
Requires Authorization header (API endpoint).
"""
vendor = request.state.vendor
is_owner = current_user.is_owner_of(vendor.id)
role_name = current_user.get_vendor_role(vendor.id)
return UserPermissionsResponse(
permissions=permissions,
permission_count=len(permissions),
is_owner=is_owner,
role_name=role_name,
)
# ============================================================================
# Statistics Routes
# ============================================================================
@vendor_team_router.get("/statistics", response_model=TeamStatistics)
def get_team_statistics(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(
require_vendor_permission(VendorPermissions.TEAM_VIEW.value)
),
):
"""
Get team statistics for the vendor.
**Required Permission:** `team.view`
**Returns:**
- Total members
- Active/inactive breakdown
- Pending invitations
- Owner count
- Role distribution
"""
vendor = request.state.vendor
members = vendor_team_service.get_team_members(
db=db, vendor=vendor, include_inactive=True
)
# Calculate statistics
total = len(members)
active = sum(1 for m in members if m["is_active"])
inactive = total - active
pending = sum(1 for m in members if m["invitation_pending"])
owners = sum(1 for m in members if m["is_owner"])
team_members = total - owners
# Role breakdown
roles_breakdown = {}
for member in members:
role = member["role_name"]
roles_breakdown[role] = roles_breakdown.get(role, 0) + 1
return TeamStatistics(
total_members=total,
active_members=active,
inactive_members=inactive,
pending_invitations=pending,
owners=owners,
team_members=team_members,
roles_breakdown=roles_breakdown,
)