feat: implement admin-users management with super admin restriction

- Add /admin/admin-users routes for managing admin users (super admin only)
- Remove vendor role from user creation form (vendors created via company hierarchy)
- Add admin-users.html and admin-user-detail.html templates
- Add admin-users.js and admin-user-detail.js for frontend logic
- Move database operations to admin_platform_service (list, get, create, delete, toggle status)
- Update sidebar to show Admin Users section only for super admins
- Add isSuperAdmin computed property to init-alpine.js
- Fix /api/v1 prefix issues in JS files (apiClient already adds prefix)
- Update architecture rule JS-012 to catch more variable patterns (url, endpoint, path)
- Replace inline SVGs with $icon() helper in select-platform.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-24 21:28:46 +01:00
parent 9d28210cf1
commit 7e68b93132
16 changed files with 1691 additions and 325 deletions

View File

@@ -358,11 +358,12 @@ javascript_rules:
CORRECT: CORRECT:
apiClient.get('/admin/vendors') apiClient.get('/admin/vendors')
apiClient.post('/admin/products') apiClient.post('/admin/products')
const apiEndpoint = '/admin/vendors' const url = '/admin/vendors'
WRONG (causes double prefix /api/v1/api/v1/...): WRONG (causes double prefix /api/v1/api/v1/...):
apiClient.get('/api/v1/admin/vendors') apiClient.get('/api/v1/admin/vendors')
const apiEndpoint = '/api/v1/admin/vendors' const url = '/api/v1/admin/vendors'
const endpoint = '/api/v1/admin/products'
Exception: Direct fetch() calls without apiClient should use full path. Exception: Direct fetch() calls without apiClient should use full path.
@@ -371,9 +372,11 @@ javascript_rules:
file_pattern: "static/**/js/**/*.js" file_pattern: "static/**/js/**/*.js"
anti_patterns: anti_patterns:
- "apiClient\\.(get|post|put|delete|patch)\\s*\\(\\s*['\"`]/api/v1" - "apiClient\\.(get|post|put|delete|patch)\\s*\\(\\s*['\"`]/api/v1"
- "apiEndpoint.*=.*['\"`]/api/v1" - "(const|let|var)\\s+(url|endpoint|apiEndpoint|apiUrl|path)\\s*=\\s*['\"`]/api/v1"
- "\\$\\{.*\\}/api/v1"
exceptions: exceptions:
- "init-api-client.js" - "init-api-client.js"
- "api-client.js"
# ============================================================================ # ============================================================================
# TEMPLATE RULES (Jinja2) # TEMPLATE RULES (Jinja2)

View File

@@ -4,9 +4,11 @@ Admin user management endpoints (Super Admin only).
This module provides endpoints for: This module provides endpoints for:
- Listing all admin users with their platform assignments - Listing all admin users with their platform assignments
- Creating platform admins - Creating platform admins and super admins
- Assigning/removing platform access - Assigning/removing platform access
- Promoting/demoting super admin status - Promoting/demoting super admin status
- Toggling admin status
- Deleting admin users
""" """
import logging import logging
@@ -18,6 +20,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_super_admin, get_current_super_admin_api from app.api.deps import get_current_super_admin, get_current_super_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import ValidationException
from app.services.admin_platform_service import admin_platform_service from app.services.admin_platform_service import admin_platform_service
from models.database.user import User from models.database.user import User
@@ -65,15 +68,16 @@ class AdminUserListResponse(BaseModel):
total: int total: int
class CreatePlatformAdminRequest(BaseModel): class CreateAdminUserRequest(BaseModel):
"""Request to create a new platform admin.""" """Request to create a new admin user (platform admin or super admin)."""
email: EmailStr email: EmailStr
username: str username: str
password: str password: str
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
platform_ids: list[int] is_super_admin: bool = False
platform_ids: list[int] = []
class AssignPlatformRequest(BaseModel): class AssignPlatformRequest(BaseModel):
@@ -89,147 +93,12 @@ class ToggleSuperAdminRequest(BaseModel):
# ============================================================================ # ============================================================================
# ENDPOINTS # HELPER FUNCTIONS
# ============================================================================ # ============================================================================
@router.get("", response_model=AdminUserListResponse) def _build_admin_response(admin: User) -> AdminUserResponse:
def list_admin_users( """Build AdminUserResponse from User model."""
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 = [] assignments = []
if not admin.is_super_admin: if not admin.is_super_admin:
for ap in admin.admin_platforms: for ap in admin.admin_platforms:
@@ -255,6 +124,111 @@ def get_admin_user(
) )
# ============================================================================
# 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.
"""
admins, total = admin_platform_service.list_admin_users(
db=db,
skip=skip,
limit=limit,
include_super_admins=include_super_admins,
)
admin_responses = [_build_admin_response(admin) for admin in admins]
return AdminUserListResponse(admins=admin_responses, total=total)
@router.post("", response_model=AdminUserResponse)
def create_admin_user(
request: CreateAdminUserRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_super_admin_api),
):
"""
Create a new admin user (super admin or platform admin).
Super admin only.
"""
# Validate platform_ids required for non-super admin
if not request.is_super_admin and not request.platform_ids:
raise ValidationException(
"Platform admins must be assigned to at least one platform",
field="platform_ids",
)
if request.is_super_admin:
# Create super admin using service
user = admin_platform_service.create_super_admin(
db=db,
email=request.email,
username=request.username,
password=request.password,
created_by_user_id=current_admin.id,
first_name=request.first_name,
last_name=request.last_name,
)
db.commit()
db.refresh(user)
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=[],
)
else:
# Create platform admin with assignments using service
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()
db.refresh(user)
return _build_admin_response(user)
@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.
"""
admin = admin_platform_service.get_admin_user(db=db, user_id=user_id)
return _build_admin_response(admin)
@router.post("/{user_id}/platforms/{platform_id}") @router.post("/{user_id}/platforms/{platform_id}")
def assign_admin_to_platform( def assign_admin_to_platform(
user_id: int = Path(...), user_id: int = Path(...),
@@ -267,7 +241,7 @@ def assign_admin_to_platform(
Super admin only. Super admin only.
""" """
assignment = admin_platform_service.assign_admin_to_platform( admin_platform_service.assign_admin_to_platform(
db=db, db=db,
admin_user_id=user_id, admin_user_id=user_id,
platform_id=platform_id, platform_id=platform_id,
@@ -275,10 +249,6 @@ def assign_admin_to_platform(
) )
db.commit() db.commit()
logger.info(
f"Assigned admin {user_id} to platform {platform_id} by {current_admin.username}"
)
return { return {
"message": "Admin assigned to platform successfully", "message": "Admin assigned to platform successfully",
"platform_id": platform_id, "platform_id": platform_id,
@@ -306,10 +276,6 @@ def remove_admin_from_platform(
) )
db.commit() db.commit()
logger.info(
f"Removed admin {user_id} from platform {platform_id} by {current_admin.username}"
)
return { return {
"message": "Admin removed from platform successfully", "message": "Admin removed from platform successfully",
"platform_id": platform_id, "platform_id": platform_id,
@@ -338,7 +304,6 @@ def toggle_super_admin_status(
db.commit() db.commit()
action = "promoted to" if request.is_super_admin else "demoted from" 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 { return {
"message": f"Admin {action} super admin successfully", "message": f"Admin {action} super admin successfully",
@@ -371,3 +336,54 @@ def get_admin_platforms(
], ],
"user_id": user_id, "user_id": user_id,
} }
@router.put("/{user_id}/status")
def toggle_admin_status(
user_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_super_admin_api),
):
"""
Toggle admin user active status.
Super admin only. Cannot deactivate yourself.
"""
admin = admin_platform_service.toggle_admin_status(
db=db,
user_id=user_id,
current_admin_id=current_admin.id,
)
db.commit()
action = "activated" if admin.is_active else "deactivated"
return {
"message": f"Admin user {action} successfully",
"user_id": user_id,
"is_active": admin.is_active,
}
@router.delete("/{user_id}")
def delete_admin_user(
user_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_super_admin_api),
):
"""
Delete an admin user.
Super admin only. Cannot delete yourself.
"""
admin_platform_service.delete_admin_user(
db=db,
user_id=user_id,
current_admin_id=current_admin.id,
)
db.commit()
return {
"message": "Admin user deleted successfully",
"user_id": user_id,
}

View File

@@ -141,7 +141,7 @@ def get_accessible_platforms(
- For platform admins: Only assigned platforms - For platform admins: Only assigned platforms
""" """
if current_user.is_super_admin: if current_user.is_super_admin:
platforms = db.query(Platform).filter(Platform.is_active == True).all() platforms = admin_platform_service.get_all_active_platforms(db)
else: else:
platforms = admin_platform_service.get_platforms_for_admin(db, current_user.id) platforms = admin_platform_service.get_platforms_for_admin(db, current_user.id)
@@ -184,14 +184,11 @@ def select_platform(
"Super admins don't need platform selection - they have global access" "Super admins don't need platform selection - they have global access"
) )
# Verify admin has access to this platform # Verify admin has access to this platform (raises exception if not)
if not current_user.can_access_platform(platform_id): admin_platform_service.validate_admin_platform_access(current_user, platform_id)
raise InsufficientPermissionsException(
f"You don't have access to this platform"
)
# Load platform # Load platform
platform = db.query(Platform).filter(Platform.id == platform_id).first() platform = admin_platform_service.get_platform_by_id(db, platform_id)
if not platform: if not platform:
raise InvalidCredentialsException("Platform not found") raise InvalidCredentialsException("Platform not found")

View File

@@ -22,7 +22,10 @@ Routes:
- GET /vendors/{vendor_code}/domains → Vendor domains management (auth required) - GET /vendors/{vendor_code}/domains → Vendor domains management (auth required)
- GET /vendor-themes → Vendor themes selection page (auth required) - GET /vendor-themes → Vendor themes selection page (auth required)
- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required) - GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required)
- GET /users → User management page (auth required) - GET /admin-users → Admin users management (super admin only)
- GET /admin-users/create → Create admin user (super admin only)
- GET /admin-users/{id} → Admin user detail (super admin only)
- GET /users → Redirects to /admin/admin-users
- GET /customers → Customer management page (auth required) - GET /customers → Customer management page (auth required)
- GET /inventory → Inventory management page (auth required) - GET /inventory → Inventory management page (auth required)
- GET /orders → Orders management page (auth required) - GET /orders → Orders management page (auth required)
@@ -396,22 +399,28 @@ async def admin_vendor_theme_page(
# ============================================================================ # ============================================================================
# USER MANAGEMENT ROUTES # ADMIN USER MANAGEMENT ROUTES (Super Admin Only)
# ============================================================================ # ============================================================================
@router.get("/users", response_class=HTMLResponse, include_in_schema=False) @router.get("/admin-users", response_class=HTMLResponse, include_in_schema=False)
async def admin_users_page( async def admin_users_list_page(
request: Request, request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header), current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Render users management page. Render admin users management page.
Shows list of all platform users. Shows list of all admin users (super admins and platform admins).
Super admin only.
""" """
from fastapi import HTTPException
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="Super admin access required")
return templates.TemplateResponse( return templates.TemplateResponse(
"admin/users.html", "admin/admin-users.html",
{ {
"request": request, "request": request,
"user": current_user, "user": current_user,
@@ -419,15 +428,21 @@ async def admin_users_page(
) )
@router.get("/users/create", response_class=HTMLResponse, include_in_schema=False) @router.get("/admin-users/create", response_class=HTMLResponse, include_in_schema=False)
async def admin_user_create_page( async def admin_user_create_page(
request: Request, request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header), current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Render user creation form. Render admin user creation form.
Super admin only.
""" """
from fastapi import HTTPException
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="Super admin access required")
return templates.TemplateResponse( return templates.TemplateResponse(
"admin/user-create.html", "admin/user-create.html",
{ {
@@ -437,7 +452,9 @@ async def admin_user_create_page(
) )
@router.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False) @router.get(
"/admin-users/{user_id}", response_class=HTMLResponse, include_in_schema=False
)
async def admin_user_detail_page( async def admin_user_detail_page(
request: Request, request: Request,
user_id: int = Path(..., description="User ID"), user_id: int = Path(..., description="User ID"),
@@ -445,10 +462,16 @@ async def admin_user_detail_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Render user detail view. Render admin user detail view.
Super admin only.
""" """
from fastapi import HTTPException
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="Super admin access required")
return templates.TemplateResponse( return templates.TemplateResponse(
"admin/user-detail.html", "admin/admin-user-detail.html",
{ {
"request": request, "request": request,
"user": current_user, "user": current_user,
@@ -458,7 +481,7 @@ async def admin_user_detail_page(
@router.get( @router.get(
"/users/{user_id}/edit", response_class=HTMLResponse, include_in_schema=False "/admin-users/{user_id}/edit", response_class=HTMLResponse, include_in_schema=False
) )
async def admin_user_edit_page( async def admin_user_edit_page(
request: Request, request: Request,
@@ -467,8 +490,14 @@ async def admin_user_edit_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Render user edit form. Render admin user edit form.
Super admin only.
""" """
from fastapi import HTTPException
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="Super admin access required")
return templates.TemplateResponse( return templates.TemplateResponse(
"admin/user-edit.html", "admin/user-edit.html",
{ {
@@ -479,6 +508,47 @@ async def admin_user_edit_page(
) )
# ============================================================================
# USER MANAGEMENT ROUTES (Legacy - Redirects)
# ============================================================================
@router.get("/users", response_class=RedirectResponse, include_in_schema=False)
async def admin_users_page_redirect():
"""
Redirect old /admin/users to /admin/admin-users.
"""
return RedirectResponse(url="/admin/admin-users", status_code=302)
@router.get("/users/create", response_class=RedirectResponse, include_in_schema=False)
async def admin_user_create_page_redirect():
"""
Redirect old /admin/users/create to /admin/admin-users/create.
"""
return RedirectResponse(url="/admin/admin-users/create", status_code=302)
@router.get(
"/users/{user_id}", response_class=RedirectResponse, include_in_schema=False
)
async def admin_user_detail_page_redirect(user_id: int = Path(..., description="User ID")):
"""
Redirect old /admin/users/{id} to /admin/admin-users/{id}.
"""
return RedirectResponse(url=f"/admin/admin-users/{user_id}", status_code=302)
@router.get(
"/users/{user_id}/edit", response_class=RedirectResponse, include_in_schema=False
)
async def admin_user_edit_page_redirect(user_id: int = Path(..., description="User ID")):
"""
Redirect old /admin/users/{id}/edit to /admin/admin-users/{id}/edit.
"""
return RedirectResponse(url=f"/admin/admin-users/{user_id}/edit", status_code=302)
# ============================================================================ # ============================================================================
# CUSTOMER MANAGEMENT ROUTES # CUSTOMER MANAGEMENT ROUTES
# ============================================================================ # ============================================================================

View File

@@ -196,6 +196,53 @@ class AdminPlatformService:
return query.all() return query.all()
def get_all_active_platforms(self, db: Session) -> list[Platform]:
"""
Get all active platforms (for super admin access).
Args:
db: Database session
Returns:
List of all active Platform objects
"""
return db.query(Platform).filter(Platform.is_active == True).all()
def get_platform_by_id(self, db: Session, platform_id: int) -> Platform | None:
"""
Get a platform by ID.
Args:
db: Database session
platform_id: Platform ID
Returns:
Platform object or None if not found
"""
return db.query(Platform).filter(Platform.id == platform_id).first()
def validate_admin_platform_access(
self,
user: User,
platform_id: int,
) -> None:
"""
Validate that an admin has access to a platform.
Args:
user: User object
platform_id: Platform ID to check
Raises:
InsufficientPermissionsException: If user doesn't have access
"""
from app.exceptions import InsufficientPermissionsException
if not user.can_access_platform(platform_id):
raise InsufficientPermissionsException(
"You don't have access to this platform"
)
def get_admins_for_platform( def get_admins_for_platform(
self, self,
db: Session, db: Session,
@@ -385,5 +432,232 @@ class AdminPlatformService:
return user, assignments return user, assignments
# ============================================================================
# ADMIN USER CRUD OPERATIONS
# ============================================================================
def list_admin_users(
self,
db: Session,
skip: int = 0,
limit: int = 100,
include_super_admins: bool = True,
is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[User], int]:
"""
List all admin users with optional filtering.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
include_super_admins: Whether to include super admins
is_active: Filter by active status
search: Search term for username/email/name
Returns:
Tuple of (list of User objects, total count)
"""
query = db.query(User).filter(User.role == "admin")
if not include_super_admins:
query = query.filter(User.is_super_admin == False)
if is_active is not None:
query = query.filter(User.is_active == is_active)
if search:
search_term = f"%{search}%"
query = query.filter(
(User.username.ilike(search_term))
| (User.email.ilike(search_term))
| (User.first_name.ilike(search_term))
| (User.last_name.ilike(search_term))
)
total = query.count()
admins = (
query.options(joinedload(User.admin_platforms))
.offset(skip)
.limit(limit)
.all()
)
return admins, total
def get_admin_user(
self,
db: Session,
user_id: int,
) -> User:
"""
Get a single admin user by ID with platform assignments.
Args:
db: Database session
user_id: User ID
Returns:
User object with admin_platforms loaded
Raises:
ValidationException: If user not found or not an admin
"""
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")
return admin
def create_super_admin(
self,
db: Session,
email: str,
username: str,
password: str,
created_by_user_id: int,
first_name: str | None = None,
last_name: str | None = None,
) -> User:
"""
Create a new super admin user.
Args:
db: Database session
email: Admin email
username: Admin username
password: Admin password
created_by_user_id: Super admin creating the account
first_name: Optional first name
last_name: Optional last name
Returns:
Created User object
"""
from app.core.security import get_password_hash
# Check for existing user
existing = (
db.query(User)
.filter((User.email == email) | (User.username == username))
.first()
)
if existing:
field = "email" if existing.email == email else "username"
raise ValidationException(f"{field.capitalize()} already exists", field=field)
user = User(
email=email,
username=username,
hashed_password=get_password_hash(password),
first_name=first_name,
last_name=last_name,
role="admin",
is_super_admin=True,
is_active=True,
)
db.add(user)
db.flush()
db.refresh(user)
logger.info(
f"Created super admin {username} by admin {created_by_user_id}"
)
return user
def toggle_admin_status(
self,
db: Session,
user_id: int,
current_admin_id: int,
) -> User:
"""
Toggle admin user active status.
Args:
db: Database session
user_id: User ID to toggle
current_admin_id: Super admin making the change
Returns:
Updated User object
Raises:
CannotModifySelfException: If trying to deactivate self
ValidationException: If user not found or not an admin
"""
if user_id == current_admin_id:
raise CannotModifySelfException(
user_id=user_id,
operation="deactivate own account",
)
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
if not admin:
raise ValidationException("Admin user not found", field="user_id")
admin.is_active = not admin.is_active
admin.updated_at = datetime.now(UTC)
db.flush()
db.refresh(admin)
action = "activated" if admin.is_active else "deactivated"
logger.info(
f"Admin {admin.username} {action} by admin {current_admin_id}"
)
return admin
def delete_admin_user(
self,
db: Session,
user_id: int,
current_admin_id: int,
) -> None:
"""
Delete an admin user and their platform assignments.
Args:
db: Database session
user_id: User ID to delete
current_admin_id: Super admin making the deletion
Raises:
CannotModifySelfException: If trying to delete self
ValidationException: If user not found or not an admin
"""
if user_id == current_admin_id:
raise CannotModifySelfException(
user_id=user_id,
operation="delete own account",
)
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
if not admin:
raise ValidationException("Admin user not found", field="user_id")
username = admin.username
# Delete admin platform assignments first
db.query(AdminPlatform).filter(AdminPlatform.user_id == user_id).delete()
# Delete the admin user
db.delete(admin)
db.flush()
logger.info(f"Admin {username} deleted by admin {current_admin_id}")
# Singleton instance # Singleton instance
admin_platform_service = AdminPlatformService() admin_platform_service = AdminPlatformService()

View File

@@ -0,0 +1,238 @@
{# app/templates/admin/admin-user-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% block title %}Admin User Details{% endblock %}
{% block alpine_data %}adminUserDetailPage(){% endblock %}
{% block content %}
{% call detail_page_header("adminUser?.full_name || adminUser?.username || 'Admin User Details'", '/admin/admin-users', subtitle_show='adminUser') %}
@<span x-text="adminUser?.username"></span>
<span class="text-gray-400 mx-2">|</span>
<span x-text="adminUser?.email"></span>
{% endcall %}
{{ loading_state('Loading admin user details...') }}
{{ error_state('Error loading admin user') }}
<!-- Admin User Details -->
<div x-show="!loading && adminUser">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<a
:href="`/admin/admin-users/${userId}/edit`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
Edit Admin User
</a>
<button
@click="toggleStatus()"
:disabled="saving || adminUser?.id === currentUserId"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
:class="adminUser?.is_active ? 'bg-orange-600 hover:bg-orange-700' : 'bg-green-600 hover:bg-green-700'"
:title="adminUser?.id === currentUserId ? 'Cannot deactivate yourself' : ''">
<span x-html="$icon(adminUser?.is_active ? 'user-x' : 'user-check', 'w-4 h-4 mr-2')"></span>
<span x-text="adminUser?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
<button
@click="deleteAdminUser()"
:disabled="saving || adminUser?.id === currentUserId"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red disabled:opacity-50 disabled:cursor-not-allowed"
:title="adminUser?.id === currentUserId ? 'Cannot delete yourself' : 'Delete admin user'">
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
Delete Admin User
</button>
</div>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Admin Type -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="adminUser?.is_super_admin
? 'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500'
: 'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500'">
<span x-html="$icon(adminUser?.is_super_admin ? 'star' : 'shield', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Admin Type
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_super_admin ? 'Super Admin' : 'Platform Admin'">
-
</p>
</div>
</div>
<!-- Active Status -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="adminUser?.is_active ? 'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500' : 'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500'">
<span x-html="$icon(adminUser?.is_active ? 'check-circle' : 'x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Status
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_active ? 'Active' : 'Inactive'">
-
</p>
</div>
</div>
<!-- Platforms Access -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Platforms
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_super_admin ? 'All' : ((adminUser?.platforms || []).length || 0)">
0
</p>
</div>
</div>
<!-- Created Date -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('calendar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Created
</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(adminUser?.created_at)">
-
</p>
</div>
</div>
</div>
<!-- Main Info Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Account Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Account Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Username</p>
<p class="text-sm text-gray-700 dark:text-gray-300">@<span x-text="adminUser?.username || '-'"></span></p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Email</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="adminUser?.email || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Email Verified</p>
<span
class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full"
:class="adminUser?.is_email_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'"
x-text="adminUser?.is_email_verified ? 'Verified' : 'Not Verified'">
</span>
</div>
</div>
</div>
<!-- Personal Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Personal Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Full Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="adminUser?.full_name || 'Not provided'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">First Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="adminUser?.first_name || 'Not provided'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Last Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="adminUser?.last_name || 'Not provided'">-</p>
</div>
</div>
</div>
</div>
<!-- Platform Access (for platform admins) -->
<template x-if="!adminUser?.is_super_admin">
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Platform Access
</h3>
<template x-if="(adminUser?.platforms || []).length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400">No platforms assigned. This admin cannot access any platform.</p>
</template>
<template x-if="(adminUser?.platforms || []).length > 0">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<template x-for="platform in (adminUser?.platforms || [])" :key="platform.id">
<div class="flex items-center p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="p-2 mr-3 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-600">
<span x-html="$icon('globe-alt', 'w-4 h-4')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="platform.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="platform.code"></p>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Super Admin Notice -->
<template x-if="adminUser?.is_super_admin">
<div class="px-4 py-3 mb-8 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div class="flex items-center">
<span x-html="$icon('star', 'w-5 h-5 text-orange-500 mr-3')"></span>
<div>
<h4 class="text-sm font-medium text-orange-800 dark:text-orange-300">Super Admin Access</h4>
<p class="text-sm text-orange-600 dark:text-orange-400">
This user has full access to all platforms and can manage other admin users.
</p>
</div>
</div>
</div>
</template>
<!-- Activity Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Activity Information
</h3>
<div class="grid gap-6 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Last Login</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="adminUser?.last_login ? formatDate(adminUser.last_login) : 'Never'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Created At</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(adminUser?.created_at)">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Last Updated</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(adminUser?.updated_at)">-</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/admin-user-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,262 @@
{# app/templates/admin/admin-users.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Admin Users{% endblock %}
{% block alpine_data %}adminUsersPage(){% endblock %}
{% block content %}
{{ page_header('Admin User Management', subtitle='Manage super admins and platform admins', action_label='Create Admin User', action_url='/admin/admin-users/create', action_icon='user-plus') }}
{{ loading_state('Loading admin users...') }}
{{ error_state('Error loading admin users') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Admin Users -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('shield', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Admins
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_admins || 0">
0
</p>
</div>
</div>
<!-- Card: Super Admins -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('star', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Super Admins
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.super_admins || 0">
0
</p>
</div>
</div>
<!-- Card: Platform Admins -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Platform Admins
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.platform_admins || 0">
0
</p>
</div>
</div>
<!-- Card: Active Admins -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('user-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_admins || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters -->
<div x-show="!loading" class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Bar -->
<div class="flex-1 max-w-md">
<div class="relative">
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search by name, email, or username..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
>
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</div>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Admin Type Filter -->
<select
x-model="filters.is_super_admin"
@change="pagination.page = 1; loadAdminUsers()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Admin Types</option>
<option value="true">Super Admins</option>
<option value="false">Platform Admins</option>
</select>
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadAdminUsers()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Refresh Button -->
<button
@click="loadAdminUsers(); loadStats()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh admin users"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Admin Users Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Admin', 'Email', 'Type', 'Platforms', 'Status', 'Last Login', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="adminUsers.length === 0">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('shield', 'w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-lg font-medium">No admin users found</p>
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Create your first admin user to get started'"></p>
</div>
</td>
</tr>
</template>
<!-- Admin User Rows -->
<template x-for="admin in adminUsers" :key="admin.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Admin Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full flex items-center justify-center text-white font-semibold text-sm"
:class="admin.is_super_admin ? 'bg-orange-500' : 'bg-purple-500'"
x-text="(admin.username || 'A').charAt(0).toUpperCase()">
</div>
</div>
<div>
<p class="font-semibold" x-text="admin.username"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="admin.full_name || ''"></p>
</div>
</div>
</td>
<!-- Email -->
<td class="px-4 py-3 text-sm" x-text="admin.email"></td>
<!-- Type -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="admin.is_super_admin
? 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'
: 'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100'"
x-text="admin.is_super_admin ? 'Super Admin' : 'Platform Admin'">
</span>
</td>
<!-- Platforms -->
<td class="px-4 py-3 text-sm">
<template x-if="admin.is_super_admin">
<span class="text-gray-500 dark:text-gray-400 italic">All platforms</span>
</template>
<template x-if="!admin.is_super_admin">
<div class="flex flex-wrap gap-1">
<template x-for="platform in (admin.platforms || []).slice(0, 3)" :key="platform.id">
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="platform.code"></span>
</template>
<template x-if="(admin.platforms || []).length > 3">
<span class="px-2 py-0.5 text-xs text-gray-500" x-text="'+' + ((admin.platforms || []).length - 3) + ' more'"></span>
</template>
<template x-if="(admin.platforms || []).length === 0">
<span class="text-gray-500 dark:text-gray-400 italic">None assigned</span>
</template>
</div>
</template>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="admin.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="admin.is_active ? 'Active' : 'Inactive'">
</span>
</td>
<!-- Last Login -->
<td class="px-4 py-3 text-sm" x-text="admin.last_login ? formatDate(admin.last_login) : 'Never'"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- View Button -->
<a
:href="'/admin/admin-users/' + admin.id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View admin user"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Edit Button -->
<a
:href="'/admin/admin-users/' + admin.id + '/edit'"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit admin user"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<!-- Delete Button (disabled for self) -->
<button
@click="deleteAdminUser(admin)"
:disabled="admin.id === currentUserId"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="admin.id === currentUserId ? 'Cannot delete yourself' : 'Delete admin user'"
>
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/admin-users.js') }}"></script>
{% endblock %}

View File

@@ -66,12 +66,21 @@
{{ menu_item('dashboard', '/admin/dashboard', 'home', 'Dashboard') }} {{ menu_item('dashboard', '/admin/dashboard', 'home', 'Dashboard') }}
</ul> </ul>
<!-- Super Admin Section (only visible to super admins) -->
<template x-if="isSuperAdmin">
<div>
{{ section_header('Super Admin', 'superAdmin') }}
{% call section_content('superAdmin') %}
{{ menu_item('admin-users', '/admin/admin-users', 'shield', 'Admin Users') }}
{% endcall %}
</div>
</template>
<!-- Platform Administration Section --> <!-- Platform Administration Section -->
{{ section_header('Platform Administration', 'platformAdmin') }} {{ section_header('Platform Administration', 'platformAdmin') }}
{% call section_content('platformAdmin') %} {% call section_content('platformAdmin') %}
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }} {{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }} {{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
{{ menu_item('users', '/admin/users', 'users', 'Users') }}
{{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }} {{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }}
{% endcall %} {% endcall %}

View File

@@ -1,4 +1,5 @@
{# app/templates/admin/select-platform.html #} {# app/templates/admin/select-platform.html #}
{# standalone - This template does not extend base.html because it's shown before platform selection #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="selectPlatform()" lang="en"> <html :class="{ 'dark': dark }" x-data="selectPlatform()" lang="en">
<head> <head>
@@ -28,10 +29,7 @@
<!-- Loading State --> <!-- Loading State -->
<div x-show="loading" class="flex justify-center py-8"> <div x-show="loading" class="flex justify-center py-8">
<svg class="animate-spin h-8 w-8 text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
<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>
</svg>
</div> </div>
<!-- Error State --> <!-- Error State -->
@@ -72,9 +70,7 @@
<!-- Arrow --> <!-- Arrow -->
<div class="flex-shrink-0 ml-4"> <div class="flex-shrink-0 ml-4">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div> </div>
</button> </button>
</template> </template>
@@ -82,9 +78,7 @@
<!-- No Platforms --> <!-- No Platforms -->
<div x-show="!loading && !isSuperAdmin && platforms.length === 0" x-cloak class="text-center py-8"> <div x-show="!loading && !isSuperAdmin && platforms.length === 0" x-cloak class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span x-html="$icon('exclamation', 'mx-auto h-12 w-12 text-gray-400')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-200">No platforms assigned</h3> <h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-200">No platforms assigned</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Contact your administrator to get platform access.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Contact your administrator to get platform access.</p>
</div> </div>
@@ -106,12 +100,8 @@
class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none" class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label="Toggle dark mode" aria-label="Toggle dark mode"
> >
<svg x-show="!dark" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <span x-show="!dark" x-html="$icon('moon', 'w-5 h-5')"></span>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path> <span x-show="dark" x-html="$icon('sun', 'w-5 h-5')"></span>
</svg>
<svg x-show="dark" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -124,6 +114,7 @@
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script> <script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script> <script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script> <script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script src="{{ url_for('static', path='admin/js/select-platform.js') }}"></script> <script src="{{ url_for('static', path='admin/js/select-platform.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
</body> </body>

View File

@@ -2,12 +2,12 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% block title %}Create User{% endblock %} {% block title %}Create Admin User{% endblock %}
{% block alpine_data %}adminUserCreate(){% endblock %} {% block alpine_data %}adminUserCreate(){% endblock %}
{% block content %} {% block content %}
{{ page_header('Create New User', subtitle='Add a new user to the platform', back_url='/admin/users', back_label='Back to Users') }} {{ page_header('Create Admin User', subtitle='Add a new admin user to manage platforms', back_url='/admin/admin-users', back_label='Back to Admin Users') }}
<!-- Create Form --> <!-- Create Form -->
<form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800"> <form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
@@ -74,29 +74,9 @@
<span x-show="errors.password" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.password"></span> <span x-show="errors.password" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.password"></span>
</label> </label>
<!-- Role --> <!-- Admin Settings -->
<label class="block mb-4 text-sm"> <div class="mt-4 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<span class="text-gray-700 dark:text-gray-400"> <h4 class="text-sm font-medium text-purple-800 dark:text-purple-300 mb-3">Admin Settings</h4>
Role <span class="text-red-600">*</span>
</span>
<select
x-model="formData.role"
@change="onRoleChange()"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
>
<option value="vendor">Vendor</option>
<option value="admin">Admin</option>
</select>
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Vendor: Can own companies and manage stores. Admin: Platform management access.
</span>
</label>
<!-- Admin-specific options -->
<template x-if="formData.role === 'admin'">
<div class="mt-4 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h4 class="text-sm font-medium text-purple-800 dark:text-purple-300 mb-3">Admin Settings</h4>
<!-- Super Admin Toggle --> <!-- Super Admin Toggle -->
<label class="flex items-center mb-4"> <label class="flex items-center mb-4">
@@ -114,37 +94,36 @@
Super admins have access to all platforms and can manage other admins. Super admins have access to all platforms and can manage other admins.
</p> </p>
<!-- Platform Assignment (only if not super admin) --> <!-- Platform Assignment (only if not super admin) -->
<template x-if="!formData.is_super_admin"> <template x-if="!formData.is_super_admin">
<div> <div>
<label class="block text-sm"> <label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400"> <span class="text-gray-700 dark:text-gray-400">
Assigned Platforms <span class="text-red-600">*</span> Assigned Platforms <span class="text-red-600">*</span>
</span> </span>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto"> <div class="mt-2 space-y-2 max-h-48 overflow-y-auto">
<template x-for="platform in platforms" :key="platform.id"> <template x-for="platform in platforms" :key="platform.id">
<label class="flex items-center p-2 rounded hover:bg-purple-100 dark:hover:bg-purple-900/30 cursor-pointer"> <label class="flex items-center p-2 rounded hover:bg-purple-100 dark:hover:bg-purple-900/30 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
:value="platform.id" :value="platform.id"
x-model="formData.platform_ids" x-model="formData.platform_ids"
:disabled="saving" :disabled="saving"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700" class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700"
> >
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300" x-text="platform.name"></span> <span class="ml-2 text-sm text-gray-700 dark:text-gray-300" x-text="platform.name"></span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400" x-text="'(' + platform.code + ')'"></span> <span class="ml-2 text-xs text-gray-500 dark:text-gray-400" x-text="'(' + platform.code + ')'"></span>
</label> </label>
</template> </template>
</div> </div>
<p x-show="platforms.length === 0" class="text-sm text-gray-500 dark:text-gray-400 mt-2"> <p x-show="platforms.length === 0" class="text-sm text-gray-500 dark:text-gray-400 mt-2">
No platforms available. Create a platform first. No platforms available. Create a platform first.
</p> </p>
<span x-show="errors.platform_ids" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.platform_ids"></span> <span x-show="errors.platform_ids" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.platform_ids"></span>
</label> </label>
</div> </div>
</template> </template>
</div> </div>
</template>
</div> </div>
<!-- Right Column: Personal Info --> <!-- Right Column: Personal Info -->
@@ -188,7 +167,7 @@
<!-- Submit Button --> <!-- Submit Button -->
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700"> <div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
<a <a
href="/admin/users" href="/admin/admin-users"
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none"> class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
Cancel Cancel
</a> </a>
@@ -198,7 +177,7 @@
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"> class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!saving"> <span x-show="!saving">
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2 inline')"></span> <span x-html="$icon('user-plus', 'w-4 h-4 mr-2 inline')"></span>
Create User Create Admin User
</span> </span>
<span x-show="saving" class="flex items-center"> <span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>

View File

@@ -0,0 +1,196 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/admin-user-detail.js
// Create custom logger for admin user detail
const adminUserDetailLog = window.LogConfig.createLogger('ADMIN-USER-DETAIL');
function adminUserDetailPage() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Admin user detail page specific state
currentPage: 'admin-users',
adminUser: null,
loading: false,
saving: false,
error: null,
userId: null,
currentUserId: null,
// Initialize
async init() {
adminUserDetailLog.info('=== ADMIN USER DETAIL PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._adminUserDetailInitialized) {
adminUserDetailLog.warn('Admin user detail page already initialized, skipping...');
return;
}
window._adminUserDetailInitialized = true;
// Get current user ID
this.currentUserId = this.adminProfile?.id || null;
// Get user ID from URL
const path = window.location.pathname;
const match = path.match(/\/admin\/admin-users\/(\d+)$/);
if (match) {
this.userId = match[1];
adminUserDetailLog.info('Viewing admin user:', this.userId);
await this.loadAdminUser();
} else {
adminUserDetailLog.error('No user ID in URL');
this.error = 'Invalid admin user URL';
Utils.showToast('Invalid admin user URL', 'error');
}
adminUserDetailLog.info('=== ADMIN USER DETAIL PAGE INITIALIZATION COMPLETE ===');
},
// Load admin user data
async loadAdminUser() {
adminUserDetailLog.info('Loading admin user details...');
this.loading = true;
this.error = null;
try {
const url = `/admin/admin-users/${this.userId}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
const startTime = performance.now();
const response = await apiClient.get(url);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('GET', url, response, 'response');
window.LogConfig.logPerformance('Load Admin User Details', duration);
// Transform API response to expected format
this.adminUser = {
...response,
platforms: (response.platform_assignments || []).map(pa => ({
id: pa.platform_id,
code: pa.platform_code,
name: pa.platform_name
})),
full_name: [response.first_name, response.last_name].filter(Boolean).join(' ') || null
};
adminUserDetailLog.info(`Admin user loaded in ${duration}ms`, {
id: this.adminUser.id,
username: this.adminUser.username,
is_super_admin: this.adminUser.is_super_admin,
is_active: this.adminUser.is_active
});
adminUserDetailLog.debug('Full admin user data:', this.adminUser);
} catch (error) {
window.LogConfig.logError(error, 'Load Admin User Details');
this.error = error.message || 'Failed to load admin user details';
Utils.showToast('Failed to load admin user details', 'error');
} finally {
this.loading = false;
}
},
// Format date
formatDate(dateString) {
if (!dateString) {
return '-';
}
return Utils.formatDate(dateString);
},
// Toggle admin user status
async toggleStatus() {
const action = this.adminUser.is_active ? 'deactivate' : 'activate';
adminUserDetailLog.info(`Toggle status: ${action}`);
// Prevent self-deactivation
if (this.adminUser.id === this.currentUserId) {
Utils.showToast('You cannot deactivate your own account', 'error');
return;
}
if (!confirm(`Are you sure you want to ${action} "${this.adminUser.username}"?`)) {
adminUserDetailLog.info('Status toggle cancelled by user');
return;
}
this.saving = true;
try {
const url = `/admin/admin-users/${this.userId}/status`;
window.LogConfig.logApiCall('PUT', url, null, 'request');
const response = await apiClient.put(url);
window.LogConfig.logApiCall('PUT', url, response, 'response');
this.adminUser.is_active = response.is_active;
Utils.showToast(`Admin user ${action}d successfully`, 'success');
adminUserDetailLog.info(`Admin user ${action}d successfully`);
} catch (error) {
window.LogConfig.logError(error, `Toggle Status (${action})`);
Utils.showToast(error.message || `Failed to ${action} admin user`, 'error');
} finally {
this.saving = false;
}
},
// Delete admin user
async deleteAdminUser() {
adminUserDetailLog.info('Delete admin user requested:', this.userId);
// Prevent self-deletion
if (this.adminUser.id === this.currentUserId) {
Utils.showToast('You cannot delete your own account', 'error');
return;
}
if (!confirm(`Are you sure you want to delete admin user "${this.adminUser.username}"?\n\nThis action cannot be undone.`)) {
adminUserDetailLog.info('Delete cancelled by user');
return;
}
// Second confirmation for safety
if (!confirm(`FINAL CONFIRMATION\n\nAre you absolutely sure you want to delete "${this.adminUser.username}"?`)) {
adminUserDetailLog.info('Delete cancelled by user (second confirmation)');
return;
}
this.saving = true;
try {
const url = `/admin/admin-users/${this.userId}`;
window.LogConfig.logApiCall('DELETE', url, null, 'request');
await apiClient.delete(url);
window.LogConfig.logApiCall('DELETE', url, null, 'response');
Utils.showToast('Admin user deleted successfully', 'success');
adminUserDetailLog.info('Admin user deleted successfully');
// Redirect to admin users list
setTimeout(() => window.location.href = '/admin/admin-users', 1500);
} catch (error) {
window.LogConfig.logError(error, 'Delete Admin User');
Utils.showToast(error.message || 'Failed to delete admin user', 'error');
} finally {
this.saving = false;
}
},
// Refresh admin user data
async refresh() {
adminUserDetailLog.info('=== ADMIN USER REFRESH TRIGGERED ===');
await this.loadAdminUser();
Utils.showToast('Admin user details refreshed', 'success');
adminUserDetailLog.info('=== ADMIN USER REFRESH COMPLETE ===');
}
};
}
adminUserDetailLog.info('Admin user detail module loaded');

View File

@@ -0,0 +1,330 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/admin-users.js
// Create custom logger for admin users
const adminUsersLog = window.LogConfig.createLogger('ADMIN-USERS');
function adminUsersPage() {
return {
// Inherit base layout functionality
...data(),
// Set page identifier
currentPage: 'admin-users',
// State
adminUsers: [],
loading: false,
error: null,
currentUserId: null,
filters: {
search: '',
is_super_admin: '',
is_active: ''
},
stats: {
total_admins: 0,
super_admins: 0,
platform_admins: 0,
active_admins: 0
},
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// Initialization
async init() {
adminUsersLog.info('=== ADMIN USERS PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._adminUsersInitialized) {
adminUsersLog.warn('Admin users page already initialized, skipping...');
return;
}
window._adminUsersInitialized = true;
// Get current user ID
this.currentUserId = this.adminProfile?.id || null;
// Load platform settings for rows per page
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadAdminUsers();
await this.loadStats();
adminUsersLog.info('=== ADMIN USERS PAGE INITIALIZATION COMPLETE ===');
},
// Format date helper
formatDate(dateString) {
if (!dateString) return '-';
return Utils.formatDate(dateString);
},
// Computed: Total number of pages
get totalPages() {
return this.pagination.pages;
},
// Computed: Start index for pagination display
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
// Computed: End index for pagination display
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
// Computed: Generate page numbers array with ellipsis
get pageNumbers() {
const pages = [];
const totalPages = this.totalPages;
const current = this.pagination.page;
if (totalPages <= 7) {
// Show all pages if 7 or fewer
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (current > 3) {
pages.push('...');
}
// Show pages around current page
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
},
// Load admin users from API
async loadAdminUsers() {
adminUsersLog.info('Loading admin users...');
this.loading = true;
this.error = null;
try {
const params = new URLSearchParams();
// Calculate skip for pagination
const skip = (this.pagination.page - 1) * this.pagination.per_page;
params.append('skip', skip);
params.append('limit', this.pagination.per_page);
if (this.filters.search) {
params.append('search', this.filters.search);
}
if (this.filters.is_super_admin === 'false') {
params.append('include_super_admins', 'false');
}
if (this.filters.is_active !== '') {
params.append('is_active', this.filters.is_active);
}
const url = `/admin/admin-users?${params}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
const startTime = performance.now();
const response = await apiClient.get(url);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('GET', url, response, 'response');
window.LogConfig.logPerformance('Load Admin Users', duration);
// Transform API response to expected format
let admins = response.admins || [];
// Apply client-side filtering for search and super admin status
if (this.filters.search) {
const searchLower = this.filters.search.toLowerCase();
admins = admins.filter(admin =>
admin.username?.toLowerCase().includes(searchLower) ||
admin.email?.toLowerCase().includes(searchLower) ||
admin.first_name?.toLowerCase().includes(searchLower) ||
admin.last_name?.toLowerCase().includes(searchLower)
);
}
// Filter by super admin status
if (this.filters.is_super_admin === 'true') {
admins = admins.filter(admin => admin.is_super_admin);
}
// Filter by active status
if (this.filters.is_active !== '') {
const isActive = this.filters.is_active === 'true';
admins = admins.filter(admin => admin.is_active === isActive);
}
// Transform platform_assignments to platforms for template
this.adminUsers = admins.map(admin => ({
...admin,
platforms: (admin.platform_assignments || []).map(pa => ({
id: pa.platform_id,
code: pa.platform_code,
name: pa.platform_name
})),
full_name: [admin.first_name, admin.last_name].filter(Boolean).join(' ') || null
}));
this.pagination.total = response.total || this.adminUsers.length;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page) || 1;
adminUsersLog.info(`Loaded ${this.adminUsers.length} admin users`);
} catch (error) {
window.LogConfig.logError(error, 'Load Admin Users');
this.error = error.message || 'Failed to load admin users';
Utils.showToast('Failed to load admin users', 'error');
} finally {
this.loading = false;
}
},
// Load statistics (computed from admin users data)
async loadStats() {
adminUsersLog.info('Loading admin user statistics...');
try {
// Fetch all admin users to compute stats
const url = '/admin/admin-users?skip=0&limit=1000';
window.LogConfig.logApiCall('GET', url, null, 'request');
const response = await apiClient.get(url);
window.LogConfig.logApiCall('GET', url, response, 'response');
const admins = response.admins || [];
// Compute stats from the data
this.stats = {
total_admins: admins.length,
super_admins: admins.filter(a => a.is_super_admin).length,
platform_admins: admins.filter(a => !a.is_super_admin).length,
active_admins: admins.filter(a => a.is_active).length
};
adminUsersLog.debug('Stats computed:', this.stats);
} catch (error) {
window.LogConfig.logError(error, 'Load Admin Stats');
// Stats are non-critical, don't show error toast
}
},
// Search with debounce
debouncedSearch() {
// Clear existing timeout
if (this._searchTimeout) {
clearTimeout(this._searchTimeout);
}
// Set new timeout
this._searchTimeout = setTimeout(() => {
adminUsersLog.info('Search triggered:', this.filters.search);
this.pagination.page = 1;
this.loadAdminUsers();
}, 300);
},
// Pagination
nextPage() {
if (this.pagination.page < this.pagination.pages) {
this.pagination.page++;
adminUsersLog.info('Next page:', this.pagination.page);
this.loadAdminUsers();
}
},
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
adminUsersLog.info('Previous page:', this.pagination.page);
this.loadAdminUsers();
}
},
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
this.pagination.page = pageNum;
adminUsersLog.info('Go to page:', this.pagination.page);
this.loadAdminUsers();
}
},
// Actions
viewAdminUser(admin) {
adminUsersLog.info('View admin user:', admin.username);
window.location.href = `/admin/admin-users/${admin.id}`;
},
editAdminUser(admin) {
adminUsersLog.info('Edit admin user:', admin.username);
window.location.href = `/admin/admin-users/${admin.id}/edit`;
},
async deleteAdminUser(admin) {
adminUsersLog.warn('Delete admin user requested:', admin.username);
// Prevent self-deletion
if (admin.id === this.currentUserId) {
Utils.showToast('You cannot delete your own account', 'error');
return;
}
if (!confirm(`Are you sure you want to delete admin user "${admin.username}"?\n\nThis action cannot be undone.`)) {
adminUsersLog.info('Delete cancelled by user');
return;
}
// Second confirmation for safety
if (!confirm(`FINAL CONFIRMATION\n\nAre you absolutely sure you want to delete "${admin.username}"?`)) {
adminUsersLog.info('Delete cancelled by user (second confirmation)');
return;
}
try {
const url = `/admin/admin-users/${admin.id}`;
window.LogConfig.logApiCall('DELETE', url, null, 'request');
await apiClient.delete(url);
Utils.showToast('Admin user deleted successfully', 'success');
adminUsersLog.info('Admin user deleted successfully');
await this.loadAdminUsers();
await this.loadStats();
} catch (error) {
window.LogConfig.logError(error, 'Delete Admin User');
Utils.showToast(error.message || 'Failed to delete admin user', 'error');
}
},
openCreateModal() {
adminUsersLog.info('Open create admin user page');
window.location.href = '/admin/admin-users/create';
}
};
}
adminUsersLog.info('Admin users module loaded');

View File

@@ -27,9 +27,11 @@ function data() {
// Default state: Platform Administration open, others closed // Default state: Platform Administration open, others closed
const defaultSections = { const defaultSections = {
superAdmin: true, // Super admin section (only visible to super admins)
platformAdmin: true, platformAdmin: true,
vendorOps: false, vendorOps: false,
marketplace: false, marketplace: false,
billing: false,
contentMgmt: false, contentMgmt: false,
devTools: false, devTools: false,
platformHealth: false, platformHealth: false,
@@ -75,12 +77,28 @@ function data() {
} }
} }
// Helper to get admin profile from localStorage
function getAdminProfileFromStorage() {
try {
// Check admin_user first (set by login), then adminProfile (legacy)
const stored = window.localStorage.getItem('admin_user') ||
window.localStorage.getItem('adminProfile');
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to parse admin profile from localStorage:', e);
}
return null;
}
// Map pages to their parent sections // Map pages to their parent sections
const pageSectionMap = { const pageSectionMap = {
// Super Admin section
'admin-users': 'superAdmin',
// Platform Administration // Platform Administration
companies: 'platformAdmin', companies: 'platformAdmin',
vendors: 'platformAdmin', vendors: 'platformAdmin',
users: 'platformAdmin',
messages: 'platformAdmin', messages: 'platformAdmin',
// Vendor Operations (Products, Customers, Inventory, Orders, Shipping) // Vendor Operations (Products, Customers, Inventory, Orders, Shipping)
'marketplace-products': 'vendorOps', 'marketplace-products': 'vendorOps',
@@ -185,7 +203,16 @@ function data() {
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
// Page identifier - will be set by individual pages // Page identifier - will be set by individual pages
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
currentPage: '' currentPage: '',
// ─────────────────────────────────────────────────────────────────
// Admin profile and super admin flag
// ─────────────────────────────────────────────────────────────────
adminProfile: getAdminProfileFromStorage(),
get isSuperAdmin() {
return this.adminProfile?.is_super_admin === true;
}
} }
} }

View File

@@ -187,7 +187,7 @@ function adminLogin() {
// Check if platform selection is required // Check if platform selection is required
try { try {
loginLog.info('Checking accessible platforms...'); loginLog.info('Checking accessible platforms...');
const platformsResponse = await apiClient.get('/api/v1/admin/auth/accessible-platforms'); const platformsResponse = await apiClient.get('/admin/auth/accessible-platforms');
loginLog.debug('Accessible platforms response:', platformsResponse); loginLog.debug('Accessible platforms response:', platformsResponse);
if (platformsResponse.requires_platform_selection) { if (platformsResponse.requires_platform_selection) {

View File

@@ -15,6 +15,13 @@ function selectPlatform() {
async init() { async init() {
platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ==='); platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._platformSelectInitialized) {
platformLog.warn('Platform selection page already initialized, skipping...');
return;
}
window._platformSelectInitialized = true;
// Set theme // Set theme
this.dark = localStorage.getItem('theme') === 'dark'; this.dark = localStorage.getItem('theme') === 'dark';
@@ -36,7 +43,7 @@ function selectPlatform() {
try { try {
platformLog.info('Fetching accessible platforms...'); platformLog.info('Fetching accessible platforms...');
const response = await apiClient.get('/api/v1/admin/auth/accessible-platforms'); const response = await apiClient.get('/admin/auth/accessible-platforms');
platformLog.debug('Platforms response:', response); platformLog.debug('Platforms response:', response);
this.isSuperAdmin = response.is_super_admin; this.isSuperAdmin = response.is_super_admin;
@@ -83,7 +90,7 @@ function selectPlatform() {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/api/v1/admin/auth/select-platform?platform_id=${platform.id}` `/admin/auth/select-platform?platform_id=${platform.id}`
); );
platformLog.debug('Platform selection response:', response); platformLog.debug('Platform selection response:', response);
@@ -125,25 +132,20 @@ function selectPlatform() {
} }
}, },
logout() { async logout() {
platformLog.info('Logging out...'); platformLog.info('Logging out...');
fetch('/api/v1/admin/auth/logout', { try {
method: 'POST', await apiClient.post('/admin/auth/logout');
headers: { } catch (error) {
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
}
})
.catch((error) => {
platformLog.error('Logout API error:', error); platformLog.error('Logout API error:', error);
}) } finally {
.finally(() => {
localStorage.removeItem('admin_token'); localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user'); localStorage.removeItem('admin_user');
localStorage.removeItem('admin_platform'); localStorage.removeItem('admin_platform');
localStorage.removeItem('token'); localStorage.removeItem('token');
window.location.href = '/admin/login'; window.location.href = '/admin/login';
}); }
}, },
toggleDarkMode() { toggleDarkMode() {

View File

@@ -1,15 +1,15 @@
// static/admin/js/user-create.js // static/admin/js/user-create.js
// Create custom logger for user create // Create custom logger for admin user create
const userCreateLog = window.LogConfig.createLogger('USER-CREATE'); const userCreateLog = window.LogConfig.createLogger('ADMIN-USER-CREATE');
function adminUserCreate() { function adminUserCreate() {
return { return {
// Inherit base layout functionality from init-alpine.js // Inherit base layout functionality from init-alpine.js
...data(), ...data(),
// User create page specific state // Admin user create page specific state
currentPage: 'user-create', currentPage: 'admin-users',
loading: false, loading: false,
formData: { formData: {
username: '', username: '',
@@ -17,7 +17,6 @@ function adminUserCreate() {
password: '', password: '',
first_name: '', first_name: '',
last_name: '', last_name: '',
role: 'vendor',
is_super_admin: false, is_super_admin: false,
platform_ids: [] platform_ids: []
}, },
@@ -27,11 +26,11 @@ function adminUserCreate() {
// Initialize // Initialize
async init() { async init() {
userCreateLog.info('=== USER CREATE PAGE INITIALIZING ==='); userCreateLog.info('=== ADMIN USER CREATE PAGE INITIALIZING ===');
// Prevent multiple initializations // Prevent multiple initializations
if (window._userCreateInitialized) { if (window._userCreateInitialized) {
userCreateLog.warn('User create page already initialized, skipping...'); userCreateLog.warn('Admin user create page already initialized, skipping...');
return; return;
} }
window._userCreateInitialized = true; window._userCreateInitialized = true;
@@ -39,7 +38,7 @@ function adminUserCreate() {
// Load platforms for admin assignment // Load platforms for admin assignment
await this.loadPlatforms(); await this.loadPlatforms();
userCreateLog.info('=== USER CREATE PAGE INITIALIZATION COMPLETE ==='); userCreateLog.info('=== ADMIN USER CREATE PAGE INITIALIZATION COMPLETE ===');
}, },
// Load available platforms // Load available platforms
@@ -55,16 +54,6 @@ function adminUserCreate() {
} }
}, },
// Handle role change
onRoleChange() {
userCreateLog.debug('Role changed to:', this.formData.role);
if (this.formData.role !== 'admin') {
// Reset admin-specific fields when switching away from admin
this.formData.is_super_admin = false;
this.formData.platform_ids = [];
}
},
// Validate form // Validate form
validateForm() { validateForm() {
this.errors = {}; this.errors = {};
@@ -79,8 +68,8 @@ function adminUserCreate() {
this.errors.password = 'Password must be at least 6 characters'; this.errors.password = 'Password must be at least 6 characters';
} }
// Admin-specific validation // Platform admin validation: must have at least one platform
if (this.formData.role === 'admin' && !this.formData.is_super_admin) { if (!this.formData.is_super_admin) {
if (!this.formData.platform_ids || this.formData.platform_ids.length === 0) { if (!this.formData.platform_ids || this.formData.platform_ids.length === 0) {
this.errors.platform_ids = 'Platform admins must be assigned to at least one platform'; this.errors.platform_ids = 'Platform admins must be assigned to at least one platform';
} }
@@ -91,7 +80,7 @@ function adminUserCreate() {
// Submit form // Submit form
async handleSubmit() { async handleSubmit() {
userCreateLog.info('=== CREATING USER ==='); userCreateLog.info('=== CREATING ADMIN USER ===');
userCreateLog.debug('Form data:', { ...this.formData, password: '[REDACTED]' }); userCreateLog.debug('Form data:', { ...this.formData, password: '[REDACTED]' });
if (!this.validateForm()) { if (!this.validateForm()) {
@@ -103,55 +92,38 @@ function adminUserCreate() {
this.saving = true; this.saving = true;
try { try {
let url, payload, response; // Use admin-users endpoint for creating admin users
const url = '/admin/admin-users';
if (this.formData.role === 'admin') { const payload = {
// Use admin-users endpoint for creating admin users email: this.formData.email,
url = '/api/v1/admin/admin-users'; username: this.formData.username,
payload = { password: this.formData.password,
email: this.formData.email, first_name: this.formData.first_name || null,
username: this.formData.username, last_name: this.formData.last_name || null,
password: this.formData.password, is_super_admin: this.formData.is_super_admin,
first_name: this.formData.first_name || null, platform_ids: this.formData.is_super_admin ? [] : this.formData.platform_ids.map(id => parseInt(id))
last_name: this.formData.last_name || null, };
is_super_admin: this.formData.is_super_admin,
platform_ids: this.formData.is_super_admin ? [] : this.formData.platform_ids.map(id => parseInt(id))
};
} else {
// Use regular users endpoint for vendor users
url = '/admin/users';
payload = {
email: this.formData.email,
username: this.formData.username,
password: this.formData.password,
first_name: this.formData.first_name || null,
last_name: this.formData.last_name || null,
role: this.formData.role
};
}
window.LogConfig.logApiCall('POST', url, { ...payload, password: '[REDACTED]' }, 'request'); window.LogConfig.logApiCall('POST', url, { ...payload, password: '[REDACTED]' }, 'request');
const startTime = performance.now(); const startTime = performance.now();
response = await apiClient.post(url, payload); const response = await apiClient.post(url, payload);
const duration = performance.now() - startTime; const duration = performance.now() - startTime;
window.LogConfig.logApiCall('POST', url, response, 'response'); window.LogConfig.logApiCall('POST', url, response, 'response');
window.LogConfig.logPerformance('Create User', duration); window.LogConfig.logPerformance('Create Admin User', duration);
const userType = this.formData.role === 'admin' const userType = this.formData.is_super_admin ? 'Super admin' : 'Platform admin';
? (this.formData.is_super_admin ? 'Super admin' : 'Platform admin')
: 'User';
Utils.showToast(`${userType} created successfully`, 'success'); Utils.showToast(`${userType} created successfully`, 'success');
userCreateLog.info(`${userType} created successfully in ${duration}ms`, response); userCreateLog.info(`${userType} created successfully in ${duration}ms`, response);
// Redirect to the new user's detail page // Redirect to the admin users list
setTimeout(() => { setTimeout(() => {
window.location.href = `/admin/users/${response.id}`; window.location.href = `/admin/admin-users/${response.id}`;
}, 1500); }, 1500);
} catch (error) { } catch (error) {
window.LogConfig.logError(error, 'Create User'); window.LogConfig.logError(error, 'Create Admin User');
// Handle validation errors // Handle validation errors
if (error.details && error.details.validation_errors) { if (error.details && error.details.validation_errors) {
@@ -173,13 +145,13 @@ function adminUserCreate() {
} }
} }
Utils.showToast(error.message || 'Failed to create user', 'error'); Utils.showToast(error.message || 'Failed to create admin user', 'error');
} finally { } finally {
this.saving = false; this.saving = false;
userCreateLog.info('=== USER CREATION COMPLETE ==='); userCreateLog.info('=== ADMIN USER CREATION COMPLETE ===');
} }
} }
}; };
} }
userCreateLog.info('User create module loaded'); userCreateLog.info('Admin user create module loaded');