feat: implement super admin and platform admin roles
Add multi-platform admin authorization system with: - AdminPlatform junction table for admin-platform assignments - is_super_admin flag on User model for global admin access - Platform selection flow for platform admins after login - JWT token updates to include platform context - New API endpoints for admin user management (super admin only) - Auth dependencies for super admin and platform access checks Includes comprehensive test coverage: - Unit tests for AdminPlatform model and User admin methods - Unit tests for AdminPlatformService operations - Integration tests for admin users API endpoints Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
168
app/api/deps.py
168
app/api/deps.py
@@ -203,6 +203,174 @@ def get_current_admin_api(
|
||||
return user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SUPER ADMIN AUTHENTICATION
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_current_super_admin(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
admin_token: str | None = Cookie(None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Require super admin role.
|
||||
|
||||
Used for: Global settings, user management, platform creation/deletion,
|
||||
admin-platform assignment management.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
credentials: Optional Bearer token from header
|
||||
admin_token: Optional token from admin_token cookie
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User: Authenticated super admin user
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If no token or invalid token
|
||||
AdminRequiredException: If user is not admin or not super admin
|
||||
"""
|
||||
user = get_current_admin_from_cookie_or_header(request, credentials, admin_token, db)
|
||||
|
||||
if not user.is_super_admin:
|
||||
logger.warning(
|
||||
f"Platform admin {user.username} attempted super admin route: {request.url.path}"
|
||||
)
|
||||
raise AdminRequiredException("Super admin privileges required")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_current_super_admin_api(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Require super admin role (API header only).
|
||||
|
||||
Used for super admin API endpoints that should not accept cookies.
|
||||
|
||||
Args:
|
||||
credentials: Bearer token from Authorization header
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User: Authenticated super admin user
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If no token or invalid token
|
||||
AdminRequiredException: If user is not admin or not super admin
|
||||
"""
|
||||
user = get_current_admin_api(credentials, db)
|
||||
|
||||
if not user.is_super_admin:
|
||||
logger.warning(f"Platform admin {user.username} attempted super admin API")
|
||||
raise AdminRequiredException("Super admin privileges required")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_platform_access(platform_id: int):
|
||||
"""
|
||||
Dependency factory to require admin access to a specific platform.
|
||||
|
||||
Super admins can access all platforms.
|
||||
Platform admins can only access their assigned platforms.
|
||||
|
||||
Usage:
|
||||
@router.get("/platforms/{platform_id}/vendors")
|
||||
def list_vendors(
|
||||
platform_id: int,
|
||||
admin: User = Depends(require_platform_access(platform_id))
|
||||
):
|
||||
...
|
||||
"""
|
||||
|
||||
def _check_platform_access(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
admin_token: str | None = Cookie(None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
user = get_current_admin_from_cookie_or_header(
|
||||
request, credentials, admin_token, db
|
||||
)
|
||||
|
||||
if not user.can_access_platform(platform_id):
|
||||
logger.warning(
|
||||
f"Admin {user.username} denied access to platform_id={platform_id}"
|
||||
)
|
||||
raise InsufficientPermissionsException(
|
||||
f"Access denied to platform {platform_id}"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
return _check_platform_access
|
||||
|
||||
|
||||
def get_admin_with_platform_context(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
admin_token: str | None = Cookie(None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get admin user and verify platform context from token.
|
||||
|
||||
For platform admins, extracts platform_id from JWT token and verifies access.
|
||||
Stores platform in request.state.admin_platform for endpoint use.
|
||||
|
||||
Super admins bypass platform context check (they can access all platforms).
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
credentials: Optional Bearer token from header
|
||||
admin_token: Optional token from admin_token cookie
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User: Authenticated admin with platform context
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If platform admin token missing platform info
|
||||
InsufficientPermissionsException: If platform access revoked
|
||||
"""
|
||||
from models.database.platform import Platform
|
||||
|
||||
user = get_current_admin_from_cookie_or_header(request, credentials, admin_token, db)
|
||||
|
||||
# Super admins bypass platform context
|
||||
if user.is_super_admin:
|
||||
return user
|
||||
|
||||
# Platform admins need platform_id in token
|
||||
if not hasattr(user, "token_platform_id"):
|
||||
raise InvalidTokenException(
|
||||
"Token missing platform information. Please select a platform."
|
||||
)
|
||||
|
||||
platform_id = user.token_platform_id
|
||||
|
||||
# Verify admin still has access to this platform
|
||||
if not user.can_access_platform(platform_id):
|
||||
logger.warning(
|
||||
f"Admin {user.username} lost access to platform_id={platform_id}"
|
||||
)
|
||||
raise InsufficientPermissionsException(
|
||||
"Access to this platform has been revoked. Please login again."
|
||||
)
|
||||
|
||||
# Load platform and store in request state
|
||||
platform = db.query(Platform).filter(Platform.id == platform_id).first()
|
||||
request.state.admin_platform = platform
|
||||
|
||||
return user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR AUTHENTICATION
|
||||
# ============================================================================
|
||||
|
||||
@@ -25,6 +25,7 @@ from fastapi import APIRouter
|
||||
|
||||
# Import all admin routers
|
||||
from . import (
|
||||
admin_users,
|
||||
audit,
|
||||
auth,
|
||||
background_tasks,
|
||||
@@ -103,6 +104,9 @@ router.include_router(platforms.router, tags=["admin-platforms"])
|
||||
# Include user management endpoints
|
||||
router.include_router(users.router, tags=["admin-users"])
|
||||
|
||||
# Include admin user management endpoints (super admin only)
|
||||
router.include_router(admin_users.router, tags=["admin-admin-users"])
|
||||
|
||||
# Include customer management endpoints
|
||||
router.include_router(customers.router, tags=["admin-customers"])
|
||||
|
||||
|
||||
373
app/api/v1/admin/admin_users.py
Normal file
373
app/api/v1/admin/admin_users.py
Normal file
@@ -0,0 +1,373 @@
|
||||
# app/api/v1/admin/admin_users.py
|
||||
"""
|
||||
Admin user management endpoints (Super Admin only).
|
||||
|
||||
This module provides endpoints for:
|
||||
- Listing all admin users with their platform assignments
|
||||
- Creating platform admins
|
||||
- Assigning/removing platform access
|
||||
- Promoting/demoting super admin status
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_super_admin, get_current_super_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_platform_service import admin_platform_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/admin-users")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PlatformAssignmentResponse(BaseModel):
|
||||
"""Response for a platform assignment."""
|
||||
|
||||
platform_id: int
|
||||
platform_code: str
|
||||
platform_name: str
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
"""Response for an admin user."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_super_admin: bool
|
||||
platform_assignments: list[PlatformAssignmentResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
"""Response for listing admin users."""
|
||||
|
||||
admins: list[AdminUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class CreatePlatformAdminRequest(BaseModel):
|
||||
"""Request to create a new platform admin."""
|
||||
|
||||
email: EmailStr
|
||||
username: str
|
||||
password: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
platform_ids: list[int]
|
||||
|
||||
|
||||
class AssignPlatformRequest(BaseModel):
|
||||
"""Request to assign admin to platform."""
|
||||
|
||||
platform_id: int
|
||||
|
||||
|
||||
class ToggleSuperAdminRequest(BaseModel):
|
||||
"""Request to toggle super admin status."""
|
||||
|
||||
is_super_admin: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=AdminUserListResponse)
|
||||
def list_admin_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
include_super_admins: bool = Query(True),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
List all admin users with their platform assignments.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
query = db.query(User).filter(User.role == "admin")
|
||||
|
||||
if not include_super_admins:
|
||||
query = query.filter(User.is_super_admin == False)
|
||||
|
||||
total = query.count()
|
||||
|
||||
admins = (
|
||||
query.options(joinedload(User.admin_platforms))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
admin_responses = []
|
||||
for admin in admins:
|
||||
assignments = []
|
||||
if not admin.is_super_admin:
|
||||
for ap in admin.admin_platforms:
|
||||
if ap.is_active and ap.platform:
|
||||
assignments.append(
|
||||
PlatformAssignmentResponse(
|
||||
platform_id=ap.platform_id,
|
||||
platform_code=ap.platform.code,
|
||||
platform_name=ap.platform.name,
|
||||
is_active=ap.is_active,
|
||||
)
|
||||
)
|
||||
|
||||
admin_responses.append(
|
||||
AdminUserResponse(
|
||||
id=admin.id,
|
||||
email=admin.email,
|
||||
username=admin.username,
|
||||
first_name=admin.first_name,
|
||||
last_name=admin.last_name,
|
||||
is_active=admin.is_active,
|
||||
is_super_admin=admin.is_super_admin,
|
||||
platform_assignments=assignments,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminUserListResponse(admins=admin_responses, total=total)
|
||||
|
||||
|
||||
@router.post("", response_model=AdminUserResponse)
|
||||
def create_platform_admin(
|
||||
request: CreatePlatformAdminRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Create a new platform admin with platform assignments.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
user, assignments = admin_platform_service.create_platform_admin(
|
||||
db=db,
|
||||
email=request.email,
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
platform_ids=request.platform_ids,
|
||||
created_by_user_id=current_admin.id,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh to get relationships
|
||||
db.refresh(user)
|
||||
|
||||
assignment_responses = [
|
||||
PlatformAssignmentResponse(
|
||||
platform_id=ap.platform_id,
|
||||
platform_code=ap.platform.code if ap.platform else "",
|
||||
platform_name=ap.platform.name if ap.platform else "",
|
||||
is_active=ap.is_active,
|
||||
)
|
||||
for ap in user.admin_platforms
|
||||
if ap.is_active
|
||||
]
|
||||
|
||||
logger.info(f"Created platform admin {user.username} by {current_admin.username}")
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
is_active=user.is_active,
|
||||
is_super_admin=user.is_super_admin,
|
||||
platform_assignments=assignment_responses,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=AdminUserResponse)
|
||||
def get_admin_user(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get admin user details with platform assignments.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
|
||||
admin = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.admin_platforms))
|
||||
.filter(User.id == user_id, User.role == "admin")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not admin:
|
||||
raise ValidationException("Admin user not found", field="user_id")
|
||||
|
||||
assignments = []
|
||||
if not admin.is_super_admin:
|
||||
for ap in admin.admin_platforms:
|
||||
if ap.is_active and ap.platform:
|
||||
assignments.append(
|
||||
PlatformAssignmentResponse(
|
||||
platform_id=ap.platform_id,
|
||||
platform_code=ap.platform.code,
|
||||
platform_name=ap.platform.name,
|
||||
is_active=ap.is_active,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=admin.id,
|
||||
email=admin.email,
|
||||
username=admin.username,
|
||||
first_name=admin.first_name,
|
||||
last_name=admin.last_name,
|
||||
is_active=admin.is_active,
|
||||
is_super_admin=admin.is_super_admin,
|
||||
platform_assignments=assignments,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}/platforms/{platform_id}")
|
||||
def assign_admin_to_platform(
|
||||
user_id: int = Path(...),
|
||||
platform_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Assign an admin to a platform.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
assignment = admin_platform_service.assign_admin_to_platform(
|
||||
db=db,
|
||||
admin_user_id=user_id,
|
||||
platform_id=platform_id,
|
||||
assigned_by_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Assigned admin {user_id} to platform {platform_id} by {current_admin.username}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Admin assigned to platform successfully",
|
||||
"platform_id": platform_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{user_id}/platforms/{platform_id}")
|
||||
def remove_admin_from_platform(
|
||||
user_id: int = Path(...),
|
||||
platform_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Remove an admin's access to a platform.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
admin_platform_service.remove_admin_from_platform(
|
||||
db=db,
|
||||
admin_user_id=user_id,
|
||||
platform_id=platform_id,
|
||||
removed_by_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Removed admin {user_id} from platform {platform_id} by {current_admin.username}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Admin removed from platform successfully",
|
||||
"platform_id": platform_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/super-admin")
|
||||
def toggle_super_admin_status(
|
||||
user_id: int = Path(...),
|
||||
request: ToggleSuperAdminRequest = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Promote or demote an admin to/from super admin.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
user = admin_platform_service.toggle_super_admin(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
is_super_admin=request.is_super_admin,
|
||||
current_admin_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
action = "promoted to" if request.is_super_admin else "demoted from"
|
||||
logger.info(f"Admin {user.username} {action} super admin by {current_admin.username}")
|
||||
|
||||
return {
|
||||
"message": f"Admin {action} super admin successfully",
|
||||
"user_id": user_id,
|
||||
"is_super_admin": user.is_super_admin,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}/platforms")
|
||||
def get_admin_platforms(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get all platforms assigned to an admin.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
platforms = admin_platform_service.get_platforms_for_admin(db, user_id)
|
||||
|
||||
return {
|
||||
"platforms": [
|
||||
{
|
||||
"id": p.id,
|
||||
"code": p.code,
|
||||
"name": p.name,
|
||||
}
|
||||
for p in platforms
|
||||
],
|
||||
"user_id": user_id,
|
||||
}
|
||||
@@ -14,11 +14,14 @@ import logging
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.api.deps import get_current_admin_api, get_current_admin_from_cookie_or_header
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import InvalidCredentialsException
|
||||
from app.exceptions import InsufficientPermissionsException, InvalidCredentialsException
|
||||
from app.services.admin_platform_service import admin_platform_service
|
||||
from app.services.auth_service import auth_service
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.platform import Platform
|
||||
from models.database.user import User
|
||||
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse
|
||||
|
||||
@@ -123,3 +126,99 @@ def admin_logout(response: Response):
|
||||
logger.debug("Deleted admin_token cookies (both /admin and / paths)")
|
||||
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
@router.get("/accessible-platforms")
|
||||
def get_accessible_platforms(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get list of platforms this admin can access.
|
||||
|
||||
Returns:
|
||||
- For super admins: All active platforms
|
||||
- For platform admins: Only assigned platforms
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
platforms = db.query(Platform).filter(Platform.is_active == True).all()
|
||||
else:
|
||||
platforms = admin_platform_service.get_platforms_for_admin(db, current_user.id)
|
||||
|
||||
return {
|
||||
"platforms": [
|
||||
{
|
||||
"id": p.id,
|
||||
"code": p.code,
|
||||
"name": p.name,
|
||||
"logo": p.logo,
|
||||
}
|
||||
for p in platforms
|
||||
],
|
||||
"is_super_admin": current_user.is_super_admin,
|
||||
"requires_platform_selection": not current_user.is_super_admin and len(platforms) > 0,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/select-platform")
|
||||
def select_platform(
|
||||
platform_id: int,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Select platform context for platform admin.
|
||||
|
||||
Issues a new JWT token with platform context.
|
||||
Super admins skip this step (they have global access).
|
||||
|
||||
Args:
|
||||
platform_id: Platform ID to select
|
||||
|
||||
Returns:
|
||||
LoginResponse with new token containing platform context
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
raise InvalidCredentialsException(
|
||||
"Super admins don't need platform selection - they have global access"
|
||||
)
|
||||
|
||||
# Verify admin has access to this platform
|
||||
if not current_user.can_access_platform(platform_id):
|
||||
raise InsufficientPermissionsException(
|
||||
f"You don't have access to this platform"
|
||||
)
|
||||
|
||||
# Load platform
|
||||
platform = db.query(Platform).filter(Platform.id == platform_id).first()
|
||||
if not platform:
|
||||
raise InvalidCredentialsException("Platform not found")
|
||||
|
||||
# Issue new token with platform context
|
||||
auth_manager = AuthManager()
|
||||
token_data = auth_manager.create_access_token(
|
||||
user=current_user,
|
||||
platform_id=platform.id,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
# Set cookie with new token
|
||||
response.set_cookie(
|
||||
key="admin_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=should_use_secure_cookies(),
|
||||
samesite="lax",
|
||||
max_age=token_data["expires_in"],
|
||||
path="/admin",
|
||||
)
|
||||
|
||||
logger.info(f"Admin {current_user.username} selected platform {platform.code}")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=token_data["access_token"],
|
||||
token_type=token_data["token_type"],
|
||||
expires_in=token_data["expires_in"],
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user