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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
238
app/templates/admin/admin-user-detail.html
Normal file
238
app/templates/admin/admin-user-detail.html
Normal 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 %}
|
||||
262
app/templates/admin/admin-users.html
Normal file
262
app/templates/admin/admin-users.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user