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:
apiClient.get('/admin/vendors')
apiClient.post('/admin/products')
const apiEndpoint = '/admin/vendors'
const url = '/admin/vendors'
WRONG (causes double prefix /api/v1/api/v1/...):
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.
@@ -371,9 +372,11 @@ javascript_rules:
file_pattern: "static/**/js/**/*.js"
anti_patterns:
- "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:
- "init-api-client.js"
- "api-client.js"
# ============================================================================
# TEMPLATE RULES (Jinja2)

View File

@@ -4,9 +4,11 @@ Admin user management endpoints (Super Admin only).
This module provides endpoints for:
- Listing all admin users with their platform assignments
- Creating platform admins
- Creating platform admins and super admins
- Assigning/removing platform access
- Promoting/demoting super admin status
- Toggling admin status
- Deleting admin users
"""
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.core.database import get_db
from app.exceptions import ValidationException
from app.services.admin_platform_service import admin_platform_service
from models.database.user import User
@@ -65,15 +68,16 @@ class AdminUserListResponse(BaseModel):
total: int
class CreatePlatformAdminRequest(BaseModel):
"""Request to create a new platform admin."""
class CreateAdminUserRequest(BaseModel):
"""Request to create a new admin user (platform admin or super admin)."""
email: EmailStr
username: str
password: str
first_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):
@@ -89,147 +93,12 @@ class ToggleSuperAdminRequest(BaseModel):
# ============================================================================
# ENDPOINTS
# HELPER FUNCTIONS
# ============================================================================
@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")
def _build_admin_response(admin: User) -> AdminUserResponse:
"""Build AdminUserResponse from User model."""
assignments = []
if not admin.is_super_admin:
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}")
def assign_admin_to_platform(
user_id: int = Path(...),
@@ -267,7 +241,7 @@ def assign_admin_to_platform(
Super admin only.
"""
assignment = admin_platform_service.assign_admin_to_platform(
admin_platform_service.assign_admin_to_platform(
db=db,
admin_user_id=user_id,
platform_id=platform_id,
@@ -275,10 +249,6 @@ def assign_admin_to_platform(
)
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,
@@ -306,10 +276,6 @@ def remove_admin_from_platform(
)
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,
@@ -338,7 +304,6 @@ def toggle_super_admin_status(
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",
@@ -371,3 +336,54 @@ def get_admin_platforms(
],
"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
"""
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:
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"
)
# 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"
)
# Verify admin has access to this platform (raises exception if not)
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
# 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:
raise InvalidCredentialsException("Platform not found")

View File

@@ -22,7 +22,10 @@ Routes:
- GET /vendors/{vendor_code}/domains → Vendor domains management (auth required)
- GET /vendor-themes → Vendor themes selection page (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 /inventory → Inventory 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)
async def admin_users_page(
@router.get("/admin-users", response_class=HTMLResponse, include_in_schema=False)
async def admin_users_list_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render users management page.
Shows list of all platform users.
Render admin users management page.
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(
"admin/users.html",
"admin/admin-users.html",
{
"request": request,
"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(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
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(
"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(
request: Request,
user_id: int = Path(..., description="User ID"),
@@ -445,10 +462,16 @@ async def admin_user_detail_page(
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(
"admin/user-detail.html",
"admin/admin-user-detail.html",
{
"request": request,
"user": current_user,
@@ -458,7 +481,7 @@ async def admin_user_detail_page(
@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(
request: Request,
@@ -467,8 +490,14 @@ async def admin_user_edit_page(
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(
"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
# ============================================================================

View File

@@ -196,6 +196,53 @@ class AdminPlatformService:
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(
self,
db: Session,
@@ -385,5 +432,232 @@ class AdminPlatformService:
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
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') }}
</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 -->
{{ section_header('Platform Administration', 'platformAdmin') }}
{% call section_content('platformAdmin') %}
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
{{ 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') }}
{% endcall %}

View File

@@ -1,4 +1,5 @@
{# app/templates/admin/select-platform.html #}
{# standalone - This template does not extend base.html because it's shown before platform selection #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="selectPlatform()" lang="en">
<head>
@@ -28,10 +29,7 @@
<!-- Loading State -->
<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">
<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>
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
</div>
<!-- Error State -->
@@ -72,9 +70,7 @@
<!-- Arrow -->
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
</template>
@@ -82,9 +78,7 @@
<!-- No Platforms -->
<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">
<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>
<span x-html="$icon('exclamation', 'mx-auto h-12 w-12 text-gray-400')"></span>
<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>
</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"
aria-label="Toggle dark mode"
>
<svg x-show="!dark" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</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>
<span x-show="!dark" x-html="$icon('moon', 'w-5 h-5')"></span>
<span x-show="dark" x-html="$icon('sun', 'w-5 h-5')"></span>
</button>
</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/utils.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 defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
</body>

View File

@@ -2,12 +2,12 @@
{% extends "admin/base.html" %}
{% 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 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 -->
<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>
</label>
<!-- Role -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
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>
<!-- Admin Settings -->
<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 -->
<label class="flex items-center mb-4">
@@ -114,37 +94,36 @@
Super admins have access to all platforms and can manage other admins.
</p>
<!-- Platform Assignment (only if not super admin) -->
<template x-if="!formData.is_super_admin">
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">
Assigned Platforms <span class="text-red-600">*</span>
</span>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto">
<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">
<input
type="checkbox"
:value="platform.id"
x-model="formData.platform_ids"
: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"
>
<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>
</label>
</template>
</div>
<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.
</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>
</label>
</div>
</template>
</div>
</template>
<!-- Platform Assignment (only if not super admin) -->
<template x-if="!formData.is_super_admin">
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">
Assigned Platforms <span class="text-red-600">*</span>
</span>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto">
<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">
<input
type="checkbox"
:value="platform.id"
x-model="formData.platform_ids"
: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"
>
<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>
</label>
</template>
</div>
<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.
</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>
</label>
</div>
</template>
</div>
</div>
<!-- Right Column: Personal Info -->
@@ -188,7 +167,7 @@
<!-- Submit Button -->
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
<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">
Cancel
</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">
<span x-show="!saving">
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2 inline')"></span>
Create User
Create Admin User
</span>
<span x-show="saving" class="flex items-center">
<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
const defaultSections = {
superAdmin: true, // Super admin section (only visible to super admins)
platformAdmin: true,
vendorOps: false,
marketplace: false,
billing: false,
contentMgmt: false,
devTools: 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
const pageSectionMap = {
// Super Admin section
'admin-users': 'superAdmin',
// Platform Administration
companies: 'platformAdmin',
vendors: 'platformAdmin',
users: 'platformAdmin',
messages: 'platformAdmin',
// Vendor Operations (Products, Customers, Inventory, Orders, Shipping)
'marketplace-products': 'vendorOps',
@@ -185,7 +203,16 @@ function data() {
// ─────────────────────────────────────────────────────────────────
// 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
try {
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);
if (platformsResponse.requires_platform_selection) {

View File

@@ -15,6 +15,13 @@ function selectPlatform() {
async init() {
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
this.dark = localStorage.getItem('theme') === 'dark';
@@ -36,7 +43,7 @@ function selectPlatform() {
try {
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);
this.isSuperAdmin = response.is_super_admin;
@@ -83,7 +90,7 @@ function selectPlatform() {
try {
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);
@@ -125,25 +132,20 @@ function selectPlatform() {
}
},
logout() {
async logout() {
platformLog.info('Logging out...');
fetch('/api/v1/admin/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
}
})
.catch((error) => {
try {
await apiClient.post('/admin/auth/logout');
} catch (error) {
platformLog.error('Logout API error:', error);
})
.finally(() => {
} finally {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('admin_platform');
localStorage.removeItem('token');
window.location.href = '/admin/login';
});
}
},
toggleDarkMode() {

View File

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