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:
2025-12-03 21:37:23 +01:00
parent 901471bb76
commit be9c892739
11 changed files with 1987 additions and 274 deletions

View File

@@ -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"}

View File

@@ -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
# ============================================================================

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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

View 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');

View 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');

View 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');

View File

@@ -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';
}
};
}