feat(users): implement full user management CRUD
API endpoints (app/api/v1/admin/users.py):
- GET /users: Paginated list with search and filters
- POST /users: Create new user
- GET /users/{id}: Get user details with related counts
- PUT /users/{id}: Update user information
- PUT /users/{id}/status: Toggle active status
- DELETE /users/{id}: Delete user (with ownership check)
Pydantic schemas (models/schema/auth.py):
- UserCreate: For creating new users
- UserUpdate: For updating user information
- UserDetailResponse: Extended user details with counts
- UserListResponse: Paginated list response
Frontend:
- Updated users.html with server-side pagination and filters
- New user-create.html/js for user creation form
- New user-detail.html/js for viewing user details
- New user-edit.html/js for editing users
Routes added for user create, detail, and edit pages.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,42 +4,123 @@ User management endpoints for admin.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.admin_service import admin_service
|
||||
from middleware.auth import AuthManager
|
||||
from app.services.stats_service import stats_service
|
||||
from models.database.user import User
|
||||
from models.schema.auth import UserResponse
|
||||
from models.schema.auth import (
|
||||
UserCreate,
|
||||
UserDetailResponse,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse])
|
||||
@router.get("", response_model=UserListResponse)
|
||||
def get_all_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
search: str = Query("", description="Search by username or email"),
|
||||
role: str = Query("", description="Filter by role"),
|
||||
is_active: str = Query("", description="Filter by active status"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get all users (Admin only)."""
|
||||
users = admin_service.get_all_users(db=db, skip=skip, limit=limit)
|
||||
return [UserResponse.model_validate(user) for user in users]
|
||||
"""Get paginated list of all users (Admin only)."""
|
||||
query = db.query(User)
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
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))
|
||||
)
|
||||
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
if is_active:
|
||||
query = query.filter(User.is_active == (is_active.lower() == "true"))
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
# Apply pagination
|
||||
skip = (page - 1) * per_page
|
||||
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
|
||||
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(user) for user in users],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}/status")
|
||||
def toggle_user_status(
|
||||
user_id: int,
|
||||
@router.post("", response_model=UserDetailResponse)
|
||||
def create_user(
|
||||
user_data: UserCreate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Toggle user active status (Admin only)."""
|
||||
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
|
||||
return {"message": message}
|
||||
"""Create a new user (Admin only)."""
|
||||
# Check if email exists
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check if username exists
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
# Create user
|
||||
auth_manager = AuthManager()
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=auth_manager.hash_password(user_data.password),
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=user_data.role,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} created user {user.username}")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
@@ -66,9 +147,7 @@ def search_users(
|
||||
search_term = f"%{q.lower()}%"
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
(User.username.ilike(search_term)) | (User.email.ilike(search_term))
|
||||
)
|
||||
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
@@ -84,3 +163,157 @@ def search_users(
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserDetailResponse)
|
||||
def get_user_details(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get detailed user information (Admin only)."""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserDetailResponse)
|
||||
def update_user(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
user_update: UserUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update user information (Admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent changing own admin status
|
||||
if user.id == current_admin.id and user_update.role and user_update.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot change your own admin role"
|
||||
)
|
||||
|
||||
# Check email uniqueness if changing
|
||||
if user_update.email and user_update.email != user.email:
|
||||
if db.query(User).filter(User.email == user_update.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check username uniqueness if changing
|
||||
if user_update.username and user_update.username != user.username:
|
||||
if db.query(User).filter(User.username == user_update.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
# Update fields
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"Admin {current_admin.username} updated user {user.username}")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_companies_count=len(user.owned_companies),
|
||||
vendor_memberships_count=len(user.vendor_memberships),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}/status")
|
||||
def toggle_user_status(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Toggle user active status (Admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deactivating yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
||||
|
||||
user.is_active = not user.is_active
|
||||
db.commit()
|
||||
|
||||
action = "activated" if user.is_active else "deactivated"
|
||||
logger.info(f"Admin {current_admin.username} {action} user {user.username}")
|
||||
|
||||
return {"message": f"User {action} successfully", "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a user (Admin only)."""
|
||||
user = (
|
||||
db.query(User)
|
||||
.options(joinedload(User.owned_companies))
|
||||
.filter(User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting yourself
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Prevent deleting users who own companies
|
||||
if user.owned_companies:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
|
||||
)
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_admin.username} deleted user {username}")
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
@@ -387,6 +387,68 @@ async def admin_users_page(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/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.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/user-create.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/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"),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render user detail view.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/user-detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}/edit", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def admin_user_edit_page(
|
||||
request: Request,
|
||||
user_id: int = Path(..., description="User ID"),
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render user edit form.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/user-edit.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# IMPORT MANAGEMENT ROUTES
|
||||
# ============================================================================
|
||||
|
||||
174
app/templates/admin/user-create.html
Normal file
174
app/templates/admin/user-create.html
Normal file
@@ -0,0 +1,174 @@
|
||||
{# app/templates/admin/user-create.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Create User{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminUserCreate(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Create New User
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Add a new user to the platform
|
||||
</p>
|
||||
</div>
|
||||
<a href="/admin/users"
|
||||
class="flex items-center 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">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
<form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Left Column: Account Info -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Account Information
|
||||
</h3>
|
||||
|
||||
<!-- Username -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Username <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.username"
|
||||
required
|
||||
maxlength="50"
|
||||
:disabled="saving"
|
||||
placeholder="johndoe"
|
||||
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-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.username }"
|
||||
>
|
||||
<span x-show="errors.username" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.username"></span>
|
||||
<span x-show="!errors.username" class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Letters, numbers, and underscores only
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Email -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Email <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
x-model="formData.email"
|
||||
required
|
||||
:disabled="saving"
|
||||
placeholder="john@example.com"
|
||||
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-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.email }"
|
||||
>
|
||||
<span x-show="errors.email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.email"></span>
|
||||
</label>
|
||||
|
||||
<!-- Password -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Password <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
x-model="formData.password"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="saving"
|
||||
placeholder="Minimum 6 characters"
|
||||
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-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.password }"
|
||||
>
|
||||
<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"
|
||||
: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: Full platform access.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Personal Info -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Personal Information
|
||||
</h3>
|
||||
|
||||
<!-- First Name -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
First Name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.first_name"
|
||||
maxlength="100"
|
||||
:disabled="saving"
|
||||
placeholder="John"
|
||||
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-input"
|
||||
>
|
||||
</label>
|
||||
|
||||
<!-- Last Name -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Last Name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.last_name"
|
||||
maxlength="100"
|
||||
:disabled="saving"
|
||||
placeholder="Doe"
|
||||
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-input"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
|
||||
<a
|
||||
href="/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>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
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
|
||||
</span>
|
||||
<span x-show="saving" class="flex items-center">
|
||||
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
Creating...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/user-create.js') }}"></script>
|
||||
{% endblock %}
|
||||
247
app/templates/admin/user-detail.html
Normal file
247
app/templates/admin/user-detail.html
Normal file
@@ -0,0 +1,247 @@
|
||||
{# app/templates/admin/user-detail.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}User Details{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminUserDetail(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="user?.full_name || user?.username || 'User Details'">
|
||||
User Details
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-show="user">
|
||||
@<span x-text="user?.username"></span>
|
||||
<span class="text-gray-400 mx-2">|</span>
|
||||
<span x-text="user?.email"></span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="/admin/users"
|
||||
class="flex items-center 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">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading user details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading user</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Details -->
|
||||
<div x-show="!loading && user">
|
||||
<!-- 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/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 User
|
||||
</a>
|
||||
<button
|
||||
@click="toggleStatus()"
|
||||
:disabled="saving"
|
||||
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"
|
||||
:class="user?.is_active ? 'bg-orange-600 hover:bg-orange-700' : 'bg-green-600 hover:bg-green-700'">
|
||||
<span x-html="$icon(user?.is_active ? 'user-x' : 'user-check', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="user?.is_active ? 'Deactivate' : 'Activate'"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteUser()"
|
||||
:disabled="saving || user?.owned_companies_count > 0"
|
||||
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="user?.owned_companies_count > 0 ? 'Cannot delete user who owns companies' : 'Delete user'">
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Role -->
|
||||
<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="{
|
||||
'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500': user?.role === 'admin',
|
||||
'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500': user?.role === 'vendor'
|
||||
}">
|
||||
<span x-html="$icon(user?.role === 'admin' ? 'shield' : 'user', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Role
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200 capitalize" x-text="user?.role">
|
||||
-
|
||||
</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="user?.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(user?.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="user?.is_active ? 'Active' : 'Inactive'">
|
||||
-
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies Owned -->
|
||||
<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('office-building', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Companies
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="user?.owned_companies_count || 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">
|
||||
Registered
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(user?.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="user?.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="user?.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="user?.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="user?.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="user?.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="user?.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="user?.last_name || 'Not provided'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Information -->
|
||||
<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">
|
||||
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="user?.last_login ? formatDate(user.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(user?.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(user?.updated_at)">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ownership 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">
|
||||
Ownership & Memberships
|
||||
</h3>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<div class="flex items-center mb-2">
|
||||
<span x-html="$icon('office-building', 'w-5 h-5 mr-2 text-blue-500')"></span>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200">Companies Owned</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="user?.owned_companies_count || 0"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Companies where this user is the owner
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<div class="flex items-center mb-2">
|
||||
<span x-html="$icon('users', 'w-5 h-5 mr-2 text-purple-500')"></span>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200">Vendor Memberships</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="user?.vendor_memberships_count || 0"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Vendors where this user is a team member
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/user-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
288
app/templates/admin/user-edit.html
Normal file
288
app/templates/admin/user-edit.html
Normal file
@@ -0,0 +1,288 @@
|
||||
{# app/templates/admin/user-edit.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit User{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminUserEdit(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Edit User
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-show="user">
|
||||
@<span x-text="user?.username"></span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="/admin/users"
|
||||
class="flex items-center 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">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loadingUser" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading user...</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div x-show="!loadingUser && user">
|
||||
<!-- 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">
|
||||
<button
|
||||
@click="toggleStatus()"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none disabled:opacity-50"
|
||||
:class="user?.is_active ? 'bg-orange-600 hover:bg-orange-700' : 'bg-green-600 hover:bg-green-700'">
|
||||
<span x-html="$icon(user?.is_active ? 'user-x' : 'user-check', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="user?.is_active ? 'Deactivate' : 'Activate'"></span>
|
||||
</button>
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight rounded-full capitalize"
|
||||
:class="{
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': user?.role === 'admin',
|
||||
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': user?.role === 'vendor'
|
||||
}"
|
||||
x-text="user?.role">
|
||||
</span>
|
||||
<span
|
||||
x-show="user?.is_active"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100">
|
||||
Active
|
||||
</span>
|
||||
<span
|
||||
x-show="!user?.is_active"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-red-700 bg-red-100 rounded-full dark:bg-red-700 dark:text-red-100">
|
||||
Inactive
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Left Column: Account Info -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Account Information
|
||||
</h3>
|
||||
|
||||
<!-- User ID (readonly) -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
User ID
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="user?.id"
|
||||
disabled
|
||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
System-generated identifier
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Username -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Username <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.username"
|
||||
required
|
||||
maxlength="50"
|
||||
: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-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.username }"
|
||||
>
|
||||
<span x-show="errors.username" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.username"></span>
|
||||
</label>
|
||||
|
||||
<!-- Email -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Email <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
x-model="formData.email"
|
||||
required
|
||||
: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-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.email }"
|
||||
>
|
||||
<span x-show="errors.email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.email"></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"
|
||||
: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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Personal Info -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Personal Information
|
||||
</h3>
|
||||
|
||||
<!-- First Name -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
First Name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.first_name"
|
||||
maxlength="100"
|
||||
: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-input"
|
||||
>
|
||||
</label>
|
||||
|
||||
<!-- Last Name -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Last Name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.last_name"
|
||||
maxlength="100"
|
||||
: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-input"
|
||||
>
|
||||
</label>
|
||||
|
||||
<!-- Email Verified -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Email Verification
|
||||
</span>
|
||||
<div class="flex items-center mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="formData.is_email_verified"
|
||||
:disabled="saving"
|
||||
class="form-checkbox text-purple-600"
|
||||
>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">
|
||||
Email is verified
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Statistics (readonly) -->
|
||||
<template x-if="user?.owned_companies_count !== undefined">
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
User Statistics
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Companies Owned</p>
|
||||
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="user.owned_companies_count || 0"></p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Vendor Memberships</p>
|
||||
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="user.vendor_memberships_count || 0"></p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Last Login</p>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="user.last_login ? formatDate(user.last_login) : 'Never'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
|
||||
<a
|
||||
:href="`/admin/users/${userId}`"
|
||||
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>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
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('check', 'w-4 h-4 mr-2 inline')"></span>
|
||||
Save Changes
|
||||
</span>
|
||||
<span x-show="saving" class="flex items-center">
|
||||
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- More Actions Section -->
|
||||
<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">
|
||||
More Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- View User Button -->
|
||||
<a
|
||||
:href="`/admin/users/${userId}`"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:shadow-outline-blue"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
|
||||
View User
|
||||
</a>
|
||||
|
||||
<!-- Delete User Button -->
|
||||
<button
|
||||
@click="deleteUser()"
|
||||
:disabled="saving || (user?.owned_companies_count > 0)"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium 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"
|
||||
:title="user?.owned_companies_count > 0 ? 'Cannot delete user who owns companies' : 'Delete this user'"
|
||||
>
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('information-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
<span x-show="user?.owned_companies_count > 0" class="text-orange-600 dark:text-orange-400">
|
||||
User cannot be deleted while they own companies (<span x-text="user?.owned_companies_count"></span> companies).
|
||||
</span>
|
||||
<span x-show="!user?.owned_companies_count">
|
||||
User can be deleted.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/user-edit.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,270 +1,333 @@
|
||||
{# app/templates/admin/users.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Users Management - Wizamart Admin{% endblock %}
|
||||
{% block title %}Users{% endblock %}
|
||||
|
||||
{% block page_title %}Users Management{% endblock %}
|
||||
{% block alpine_data %}adminUsers(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="adminUsers()" x-init="init()">
|
||||
<!-- Header Section with Search and Actions -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<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 users by name, email, or username..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<div class="absolute left-3 top-2.5" x-html="$icon('search', 'w-5 h-5 text-gray-400')"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
User Management
|
||||
</h2>
|
||||
<a
|
||||
href="/admin/users/create"
|
||||
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('user-plus', 'w-4 h-4 mr-2')"></span>
|
||||
Create User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filter and Action Buttons -->
|
||||
<div class="flex gap-3">
|
||||
<!-- Role Filter -->
|
||||
<select
|
||||
x-model="filters.role"
|
||||
@change="loadUsers()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="loadUsers()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading users</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add User Button -->
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('user-plus', 'w-5 h-5')"></span>
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total 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-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Users
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_users || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg" x-html="$icon('users', 'w-8 h-8 text-blue-600 dark:text-blue-400')"></div>
|
||||
</div>
|
||||
<!-- Card: Active 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-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 class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Active Users</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900 rounded-lg" x-html="$icon('user-check', 'w-8 h-8 text-green-600 dark:text-green-400')"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Vendors</p>
|
||||
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.vendors"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-purple-100 dark:bg-purple-900 rounded-lg" x-html="$icon('store', 'w-8 h-8 text-purple-600 dark:text-purple-400')"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Admins</p>
|
||||
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400" x-text="stats.admins"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-orange-100 dark:bg-orange-900 rounded-lg" x-html="$icon('shield', 'w-8 h-8 text-orange-600 dark:text-orange-400')"></div>
|
||||
</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_users || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="p-8 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
<!-- Card: Inactive 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-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('user-x', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Inactive
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive_users || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: 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-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-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">
|
||||
Admins
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.admin_users || 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>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && users.length === 0" class="p-8 text-center">
|
||||
<div class="text-gray-400 dark:text-gray-500 mb-4" x-html="$icon('inbox', 'w-16 h-16 mx-auto')"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">No users found</p>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Role Filter -->
|
||||
<select
|
||||
x-model="filters.role"
|
||||
@change="pagination.page = 1; loadUsers()"
|
||||
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 Roles</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div x-show="!loading && users.length > 0">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="pagination.page = 1; loadUsers()"
|
||||
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="loadUsers(); 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 users"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div x-show="!loading" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">User</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Role</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Registered</th>
|
||||
<th class="px-4 py-3">Last Login</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="users.length === 0">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Registered
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
<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('users', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No users found</p>
|
||||
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Create your first user to get started'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="user in users" :key="user.id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<!-- User Info -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<span class="text-white font-semibold" x-text="(user.full_name || user.username || 'U').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="user.full_name || 'N/A'"></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="'@' + user.username"></div>
|
||||
</template>
|
||||
|
||||
<!-- User Rows -->
|
||||
<template x-for="user in users" :key="user.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- User 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 bg-purple-500 flex items-center justify-center text-white font-semibold text-sm"
|
||||
x-text="(user.username || 'U').charAt(0).toUpperCase()">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Email -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900 dark:text-white" x-text="user.email"></div>
|
||||
</td>
|
||||
|
||||
<!-- Role -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': user.role === 'customer',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': user.role === 'vendor',
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': user.role === 'admin'
|
||||
}"
|
||||
x-text="user.role"
|
||||
></span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
|
||||
:class="user.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'"
|
||||
x-text="user.is_active ? 'Active' : 'Inactive'"
|
||||
></span>
|
||||
</td>
|
||||
|
||||
<!-- Registered -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="Utils.formatDate(user.created_at)"></span>
|
||||
</td>
|
||||
|
||||
<!-- Last Login -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="user.last_login ? Utils.formatDate(user.last_login) : 'Never'"></span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="viewUser(user)"
|
||||
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="View Details"
|
||||
x-html="$icon('eye', 'w-5 h-5')"
|
||||
></button>
|
||||
<button
|
||||
@click="editUser(user)"
|
||||
class="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300"
|
||||
title="Edit"
|
||||
x-html="$icon('edit', 'w-5 h-5')"
|
||||
></button>
|
||||
<button
|
||||
@click="toggleUserStatus(user)"
|
||||
:class="user.is_active ? 'text-orange-600 hover:text-orange-900 dark:text-orange-400 dark:hover:text-orange-300' : 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300'"
|
||||
:title="user.is_active ? 'Deactivate' : 'Activate'"
|
||||
x-html="$icon(user.is_active ? 'user-x' : 'user-check', 'w-5 h-5')"
|
||||
></button>
|
||||
<button
|
||||
@click="deleteUser(user)"
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete"
|
||||
x-html="$icon('delete', 'w-5 h-5')"
|
||||
></button>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="user.username"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="user.full_name || ''"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing <span class="font-medium" x-text="((pagination.page - 1) * pagination.per_page) + 1"></span>
|
||||
to <span class="font-medium" x-text="Math.min(pagination.page * pagination.per_page, pagination.total)"></span>
|
||||
of <span class="font-medium" x-text="pagination.total"></span> users
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<!-- Email -->
|
||||
<td class="px-4 py-3 text-sm" x-text="user.email"></td>
|
||||
|
||||
<!-- Role -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full capitalize"
|
||||
:class="{
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': user.role === 'admin',
|
||||
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': user.role === 'vendor'
|
||||
}"
|
||||
x-text="user.role">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="user.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="user.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Registered -->
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(user.created_at)"></td>
|
||||
|
||||
<!-- Last Login -->
|
||||
<td class="px-4 py-3 text-sm" x-text="user.last_login ? formatDate(user.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/users/' + user.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 user"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
|
||||
<!-- Edit Button -->
|
||||
<a
|
||||
:href="'/admin/users/' + user.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 user"
|
||||
>
|
||||
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
@click="deleteUser(user)"
|
||||
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"
|
||||
title="Delete user"
|
||||
>
|
||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Footer -->
|
||||
<div class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
|
||||
<!-- Results Info -->
|
||||
<span class="flex items-center col-span-3">
|
||||
Showing <span class="mx-1 font-bold" x-text="startIndex"></span>-<span class="mx-1 font-bold" x-text="endIndex"></span> of <span class="mx-1 font-bold" x-text="pagination.total"></span>
|
||||
</span>
|
||||
<span class="col-span-2"></span>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
|
||||
<nav aria-label="Table navigation">
|
||||
<ul class="inline-flex items-center">
|
||||
<!-- Previous Button -->
|
||||
<li>
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="pagination.page === 1"
|
||||
:class="pagination.page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
|
||||
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300"
|
||||
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
|
||||
:class="pagination.page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
aria-label="Previous"
|
||||
>
|
||||
Previous
|
||||
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
|
||||
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<template x-for="pageNum in pageNumbers" :key="pageNum">
|
||||
<li>
|
||||
<button
|
||||
x-show="pageNum !== '...'"
|
||||
@click="goToPage(pageNum)"
|
||||
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||
:class="pagination.page === pageNum ? 'text-white bg-purple-600 border border-purple-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="pageNum"
|
||||
></button>
|
||||
<span x-show="pageNum === '...'" class="px-3 py-1">...</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- Next Button -->
|
||||
<li>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="pagination.page >= pagination.pages"
|
||||
:class="pagination.page >= pagination.pages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
|
||||
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300"
|
||||
:disabled="pagination.page === totalPages"
|
||||
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
|
||||
:class="pagination.page === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
aria-label="Next"
|
||||
>
|
||||
Next
|
||||
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
|
||||
<path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/users.js') }}"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -59,3 +59,65 @@ class LoginResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class UserDetailResponse(UserResponse):
|
||||
"""Extended user response with additional details."""
|
||||
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
full_name: str | None = None
|
||||
is_email_verified: bool = False
|
||||
owned_companies_count: int = 0
|
||||
vendor_memberships_count: int = 0
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating user information."""
|
||||
|
||||
username: str | None = Field(None, min_length=3, max_length=50)
|
||||
email: EmailStr | None = None
|
||||
first_name: str | None = Field(None, max_length=100)
|
||||
last_name: str | None = Field(None, max_length=100)
|
||||
role: str | None = Field(None, pattern="^(admin|vendor)$")
|
||||
is_active: bool | None = None
|
||||
is_email_verified: bool | None = None
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, v):
|
||||
if v and not re.match(r"^[a-zA-Z0-9_]+$", v):
|
||||
raise ValueError(
|
||||
"Username must contain only letters, numbers, or underscores"
|
||||
)
|
||||
return v.lower().strip() if v else v
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for creating a new user (admin only)."""
|
||||
|
||||
email: EmailStr = Field(..., description="Valid email address")
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str = Field(..., min_length=6, description="Password")
|
||||
first_name: str | None = Field(None, max_length=100)
|
||||
last_name: str | None = Field(None, max_length=100)
|
||||
role: str = Field(default="vendor", pattern="^(admin|vendor)$")
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, v):
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", v):
|
||||
raise ValueError(
|
||||
"Username must contain only letters, numbers, or underscores"
|
||||
)
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Schema for paginated user list."""
|
||||
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
97
static/admin/js/user-create.js
Normal file
97
static/admin/js/user-create.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// static/admin/js/user-create.js
|
||||
|
||||
// Create custom logger for user create
|
||||
const userCreateLog = window.LogConfig.createLogger('USER-CREATE');
|
||||
|
||||
function adminUserCreate() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// User create page specific state
|
||||
currentPage: 'user-create',
|
||||
formData: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
role: 'vendor'
|
||||
},
|
||||
errors: {},
|
||||
saving: false,
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
userCreateLog.info('=== USER CREATE PAGE INITIALIZING ===');
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (window._userCreateInitialized) {
|
||||
userCreateLog.warn('User create page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._userCreateInitialized = true;
|
||||
|
||||
userCreateLog.info('=== USER CREATE PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
// Submit form
|
||||
async handleSubmit() {
|
||||
userCreateLog.info('=== CREATING USER ===');
|
||||
userCreateLog.debug('Form data:', { ...this.formData, password: '[REDACTED]' });
|
||||
|
||||
this.errors = {};
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const url = `/admin/users`;
|
||||
window.LogConfig.logApiCall('POST', url, { ...this.formData, password: '[REDACTED]' }, 'request');
|
||||
|
||||
const startTime = performance.now();
|
||||
const response = await apiClient.post(url, this.formData);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
window.LogConfig.logApiCall('POST', url, response, 'response');
|
||||
window.LogConfig.logPerformance('Create User', duration);
|
||||
|
||||
Utils.showToast('User created successfully', 'success');
|
||||
userCreateLog.info(`User created successfully in ${duration}ms`, response);
|
||||
|
||||
// Redirect to the new user's detail page
|
||||
setTimeout(() => {
|
||||
window.location.href = `/admin/users/${response.id}`;
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Create User');
|
||||
|
||||
// Handle validation errors
|
||||
if (error.details && error.details.validation_errors) {
|
||||
error.details.validation_errors.forEach(err => {
|
||||
const field = err.loc?.[1] || err.loc?.[0];
|
||||
if (field) {
|
||||
this.errors[field] = err.msg;
|
||||
}
|
||||
});
|
||||
userCreateLog.debug('Validation errors:', this.errors);
|
||||
}
|
||||
|
||||
// Handle specific errors
|
||||
if (error.message) {
|
||||
if (error.message.includes('Email already registered')) {
|
||||
this.errors.email = 'This email is already registered';
|
||||
} else if (error.message.includes('Username already taken')) {
|
||||
this.errors.username = 'This username is already taken';
|
||||
}
|
||||
}
|
||||
|
||||
Utils.showToast(error.message || 'Failed to create user', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
userCreateLog.info('=== USER CREATION COMPLETE ===');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
userCreateLog.info('User create module loaded');
|
||||
175
static/admin/js/user-detail.js
Normal file
175
static/admin/js/user-detail.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// static/admin/js/user-detail.js
|
||||
|
||||
// Create custom logger for user detail
|
||||
const userDetailLog = window.LogConfig.createLogger('USER-DETAIL');
|
||||
|
||||
function adminUserDetail() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// User detail page specific state
|
||||
currentPage: 'user-detail',
|
||||
user: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
userId: null,
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
userDetailLog.info('=== USER DETAIL PAGE INITIALIZING ===');
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (window._userDetailInitialized) {
|
||||
userDetailLog.warn('User detail page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._userDetailInitialized = true;
|
||||
|
||||
// Get user ID from URL
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/admin\/users\/(\d+)$/);
|
||||
|
||||
if (match) {
|
||||
this.userId = match[1];
|
||||
userDetailLog.info('Viewing user:', this.userId);
|
||||
await this.loadUser();
|
||||
} else {
|
||||
userDetailLog.error('No user ID in URL');
|
||||
this.error = 'Invalid user URL';
|
||||
Utils.showToast('Invalid user URL', 'error');
|
||||
}
|
||||
|
||||
userDetailLog.info('=== USER DETAIL PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
// Load user data
|
||||
async loadUser() {
|
||||
userDetailLog.info('Loading user details...');
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const url = `/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 User Details', duration);
|
||||
|
||||
this.user = response;
|
||||
|
||||
userDetailLog.info(`User loaded in ${duration}ms`, {
|
||||
id: this.user.id,
|
||||
username: this.user.username,
|
||||
role: this.user.role,
|
||||
is_active: this.user.is_active
|
||||
});
|
||||
userDetailLog.debug('Full user data:', this.user);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load User Details');
|
||||
this.error = error.message || 'Failed to load user details';
|
||||
Utils.showToast('Failed to load user details', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate(dateString) {
|
||||
if (!dateString) {
|
||||
return '-';
|
||||
}
|
||||
return Utils.formatDate(dateString);
|
||||
},
|
||||
|
||||
// Toggle user status
|
||||
async toggleStatus() {
|
||||
const action = this.user.is_active ? 'deactivate' : 'activate';
|
||||
userDetailLog.info(`Toggle status: ${action}`);
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} ${this.user.username}?`)) {
|
||||
userDetailLog.info('Status toggle cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const url = `/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.user.is_active = response.is_active;
|
||||
Utils.showToast(`User ${action}d successfully`, 'success');
|
||||
userDetailLog.info(`User ${action}d successfully`);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, `Toggle Status (${action})`);
|
||||
Utils.showToast(error.message || `Failed to ${action} user`, 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete user
|
||||
async deleteUser() {
|
||||
userDetailLog.info('Delete user requested:', this.userId);
|
||||
|
||||
if (this.user?.owned_companies_count > 0) {
|
||||
Utils.showToast(`Cannot delete user who owns ${this.user.owned_companies_count} company(ies). Transfer ownership first.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${this.user.username}"?\n\nThis action cannot be undone.`)) {
|
||||
userDetailLog.info('Delete cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation for safety
|
||||
if (!confirm(`FINAL CONFIRMATION\n\nAre you absolutely sure you want to delete "${this.user.username}"?`)) {
|
||||
userDetailLog.info('Delete cancelled by user (second confirmation)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const url = `/admin/users/${this.userId}`;
|
||||
window.LogConfig.logApiCall('DELETE', url, null, 'request');
|
||||
|
||||
await apiClient.delete(url);
|
||||
|
||||
window.LogConfig.logApiCall('DELETE', url, null, 'response');
|
||||
|
||||
Utils.showToast('User deleted successfully', 'success');
|
||||
userDetailLog.info('User deleted successfully');
|
||||
|
||||
// Redirect to users list
|
||||
setTimeout(() => window.location.href = '/admin/users', 1500);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Delete User');
|
||||
Utils.showToast(error.message || 'Failed to delete user', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh user data
|
||||
async refresh() {
|
||||
userDetailLog.info('=== USER REFRESH TRIGGERED ===');
|
||||
await this.loadUser();
|
||||
Utils.showToast('User details refreshed', 'success');
|
||||
userDetailLog.info('=== USER REFRESH COMPLETE ===');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
userDetailLog.info('User detail module loaded');
|
||||
223
static/admin/js/user-edit.js
Normal file
223
static/admin/js/user-edit.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// static/admin/js/user-edit.js
|
||||
|
||||
// Create custom logger for user edit
|
||||
const userEditLog = window.LogConfig.createLogger('USER-EDIT');
|
||||
|
||||
function adminUserEdit() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// User edit page specific state
|
||||
currentPage: 'user-edit',
|
||||
user: null,
|
||||
formData: {},
|
||||
errors: {},
|
||||
loadingUser: false,
|
||||
saving: false,
|
||||
userId: null,
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
userEditLog.info('=== USER EDIT PAGE INITIALIZING ===');
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (window._userEditInitialized) {
|
||||
userEditLog.warn('User edit page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._userEditInitialized = true;
|
||||
|
||||
// Get user ID from URL
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/admin\/users\/(\d+)\/edit/);
|
||||
|
||||
if (match) {
|
||||
this.userId = parseInt(match[1], 10);
|
||||
userEditLog.info('Editing user:', this.userId);
|
||||
await this.loadUser();
|
||||
} else {
|
||||
userEditLog.error('No user ID in URL');
|
||||
Utils.showToast('Invalid user URL', 'error');
|
||||
setTimeout(() => window.location.href = '/admin/users', 2000);
|
||||
}
|
||||
|
||||
userEditLog.info('=== USER EDIT PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
// Load user data
|
||||
async loadUser() {
|
||||
userEditLog.info('Loading user data...');
|
||||
this.loadingUser = true;
|
||||
|
||||
try {
|
||||
const url = `/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 User', duration);
|
||||
|
||||
this.user = response;
|
||||
|
||||
// Initialize form data
|
||||
this.formData = {
|
||||
username: response.username || '',
|
||||
email: response.email || '',
|
||||
first_name: response.first_name || '',
|
||||
last_name: response.last_name || '',
|
||||
role: response.role || 'vendor',
|
||||
is_email_verified: response.is_email_verified || false
|
||||
};
|
||||
|
||||
userEditLog.info(`User loaded in ${duration}ms`, {
|
||||
user_id: this.user.id,
|
||||
username: this.user.username
|
||||
});
|
||||
userEditLog.debug('Form data initialized:', this.formData);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load User');
|
||||
Utils.showToast('Failed to load user', 'error');
|
||||
setTimeout(() => window.location.href = '/admin/users', 2000);
|
||||
} finally {
|
||||
this.loadingUser = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate(dateString) {
|
||||
if (!dateString) {
|
||||
return '-';
|
||||
}
|
||||
return Utils.formatDate(dateString);
|
||||
},
|
||||
|
||||
// Submit form
|
||||
async handleSubmit() {
|
||||
userEditLog.info('=== SUBMITTING USER UPDATE ===');
|
||||
userEditLog.debug('Form data:', this.formData);
|
||||
|
||||
this.errors = {};
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const url = `/admin/users/${this.userId}`;
|
||||
window.LogConfig.logApiCall('PUT', url, this.formData, 'request');
|
||||
|
||||
const startTime = performance.now();
|
||||
const response = await apiClient.put(url, this.formData);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
||||
window.LogConfig.logPerformance('Update User', duration);
|
||||
|
||||
this.user = response;
|
||||
Utils.showToast('User updated successfully', 'success');
|
||||
userEditLog.info(`User updated successfully in ${duration}ms`, response);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Update User');
|
||||
|
||||
// Handle validation errors
|
||||
if (error.details && error.details.validation_errors) {
|
||||
error.details.validation_errors.forEach(err => {
|
||||
const field = err.loc?.[1] || err.loc?.[0];
|
||||
if (field) {
|
||||
this.errors[field] = err.msg;
|
||||
}
|
||||
});
|
||||
userEditLog.debug('Validation errors:', this.errors);
|
||||
}
|
||||
|
||||
Utils.showToast(error.message || 'Failed to update user', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
userEditLog.info('=== USER UPDATE COMPLETE ===');
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle user status
|
||||
async toggleStatus() {
|
||||
const action = this.user.is_active ? 'deactivate' : 'activate';
|
||||
userEditLog.info(`Toggle status: ${action}`);
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} ${this.user.username}?`)) {
|
||||
userEditLog.info('Status toggle cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const url = `/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.user.is_active = response.is_active;
|
||||
Utils.showToast(`User ${action}d successfully`, 'success');
|
||||
userEditLog.info(`User ${action}d successfully`);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, `Toggle Status (${action})`);
|
||||
Utils.showToast(error.message || `Failed to ${action} user`, 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete user
|
||||
async deleteUser() {
|
||||
userEditLog.info('=== DELETING USER ===');
|
||||
|
||||
if (this.user.owned_companies_count > 0) {
|
||||
Utils.showToast(`Cannot delete user who owns ${this.user.owned_companies_count} company(ies). Transfer ownership first.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete user "${this.user.username}"?\n\nThis action cannot be undone.`)) {
|
||||
userEditLog.info('User deletion cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Double confirmation for critical action
|
||||
if (!confirm(`FINAL CONFIRMATION: Delete "${this.user.username}"?\n\nThis will permanently delete the user.`)) {
|
||||
userEditLog.info('User deletion cancelled at final confirmation');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const url = `/admin/users/${this.userId}`;
|
||||
|
||||
window.LogConfig.logApiCall('DELETE', url, null, 'request');
|
||||
|
||||
await apiClient.delete(url);
|
||||
|
||||
window.LogConfig.logApiCall('DELETE', url, null, 'response');
|
||||
|
||||
Utils.showToast('User deleted successfully', 'success');
|
||||
userEditLog.info('User deleted successfully');
|
||||
|
||||
// Redirect to users list
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/users';
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Delete User');
|
||||
Utils.showToast(error.message || 'Failed to delete user', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
userEditLog.info('=== USER DELETION COMPLETE ===');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
userEditLog.info('User edit module loaded');
|
||||
@@ -5,25 +5,26 @@ const usersLog = window.LogConfig.loggers.users;
|
||||
|
||||
function adminUsers() {
|
||||
return {
|
||||
// ✅ Inherit base layout functionality
|
||||
// Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// ✅ Set page identifier
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'users',
|
||||
|
||||
|
||||
// State
|
||||
users: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
search: '',
|
||||
role: '',
|
||||
is_active: ''
|
||||
},
|
||||
stats: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
vendors: 0,
|
||||
admins: 0
|
||||
total_users: 0,
|
||||
active_users: 0,
|
||||
inactive_users: 0,
|
||||
admin_users: 0
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
@@ -49,25 +50,95 @@ function adminUsers() {
|
||||
usersLog.info('=== 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 users from API
|
||||
async loadUsers() {
|
||||
usersLog.info('Loading users...');
|
||||
this.loading = true;
|
||||
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
...this.filters
|
||||
});
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', this.pagination.page);
|
||||
params.append('per_page', this.pagination.per_page);
|
||||
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.role) {
|
||||
params.append('role', this.filters.role);
|
||||
}
|
||||
if (this.filters.is_active) {
|
||||
params.append('is_active', this.filters.is_active);
|
||||
}
|
||||
|
||||
const url = `/admin/users?${params}`;
|
||||
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||
|
||||
|
||||
const startTime = performance.now();
|
||||
const response = await apiClient.get(url); // ✅ Fixed: lowercase apiClient
|
||||
const response = await apiClient.get(url);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
|
||||
window.LogConfig.logApiCall('GET', url, response, 'response');
|
||||
window.LogConfig.logPerformance('Load Users', duration);
|
||||
|
||||
@@ -75,10 +146,13 @@ function adminUsers() {
|
||||
this.users = response.items;
|
||||
this.pagination.total = response.total;
|
||||
this.pagination.pages = response.pages;
|
||||
this.pagination.page = response.page;
|
||||
this.pagination.per_page = response.per_page;
|
||||
usersLog.info(`Loaded ${this.users.length} users`);
|
||||
}
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load Users');
|
||||
this.error = error.message || 'Failed to load users';
|
||||
Utils.showToast('Failed to load users', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -107,11 +181,18 @@ function adminUsers() {
|
||||
},
|
||||
|
||||
// Search with debounce
|
||||
debouncedSearch: Utils.debounce(function() {
|
||||
usersLog.info('Search triggered:', this.filters.search);
|
||||
this.pagination.page = 1;
|
||||
this.loadUsers();
|
||||
}, 500),
|
||||
debouncedSearch() {
|
||||
// Clear existing timeout
|
||||
if (this._searchTimeout) {
|
||||
clearTimeout(this._searchTimeout);
|
||||
}
|
||||
// Set new timeout
|
||||
this._searchTimeout = setTimeout(() => {
|
||||
usersLog.info('Search triggered:', this.filters.search);
|
||||
this.pagination.page = 1;
|
||||
this.loadUsers();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Pagination
|
||||
nextPage() {
|
||||
@@ -130,15 +211,23 @@ function adminUsers() {
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(pageNum) {
|
||||
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
|
||||
this.pagination.page = pageNum;
|
||||
usersLog.info('Go to page:', this.pagination.page);
|
||||
this.loadUsers();
|
||||
}
|
||||
},
|
||||
|
||||
// Actions
|
||||
viewUser(user) {
|
||||
usersLog.info('View user:', user.username);
|
||||
// TODO: Open view modal
|
||||
window.location.href = `/admin/users/${user.id}`;
|
||||
},
|
||||
|
||||
editUser(user) {
|
||||
usersLog.info('Edit user:', user.username);
|
||||
// TODO: Open edit modal
|
||||
window.location.href = `/admin/users/${user.id}/edit`;
|
||||
},
|
||||
|
||||
async toggleUserStatus(user) {
|
||||
@@ -196,7 +285,7 @@ function adminUsers() {
|
||||
|
||||
openCreateModal() {
|
||||
usersLog.info('Open create user modal');
|
||||
// TODO: Open create modal
|
||||
window.location.href = '/admin/users/create';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user