diff --git a/app/api/v1/admin/users.py b/app/api/v1/admin/users.py index f435bc95..ba9416ce 100644 --- a/app/api/v1/admin/users.py +++ b/app/api/v1/admin/users.py @@ -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"} diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index 23d446e7..1e67cfb4 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -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 # ============================================================================ diff --git a/app/templates/admin/user-create.html b/app/templates/admin/user-create.html new file mode 100644 index 00000000..ddcfa36f --- /dev/null +++ b/app/templates/admin/user-create.html @@ -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 %} + +
+
+

+ Create New User +

+

+ Add a new user to the platform +

+
+ + + Back to Users + +
+ + +
+
+ +
+

+ Account Information +

+ + + + + + + + + + + + +
+ + +
+

+ Personal Information +

+ + + + + + +
+
+ + +
+ + Cancel + + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/admin/user-detail.html b/app/templates/admin/user-detail.html new file mode 100644 index 00000000..12586132 --- /dev/null +++ b/app/templates/admin/user-detail.html @@ -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 %} + +
+
+

+ User Details +

+

+ @ + | + +

+
+ + + Back + +
+ + +
+ +

Loading user details...

+
+ + +
+ +
+

Error loading user

+

+
+
+ + +
+ +
+

+ Quick Actions +

+
+ + + Edit User + + + +
+
+ + +
+ +
+
+ +
+
+

+ Role +

+

+ - +

+
+
+ + +
+
+ +
+
+

+ Status +

+

+ - +

+
+
+ + +
+
+ +
+
+

+ Companies +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Registered +

+

+ - +

+
+
+
+ + +
+ +
+

+ Account Information +

+
+
+

Username

+

@

+
+
+

Email

+

-

+
+
+

Email Verified

+ + +
+
+
+ + +
+

+ Personal Information +

+
+
+

Full Name

+

-

+
+
+

First Name

+

-

+
+
+

Last Name

+

-

+
+
+
+
+ + +
+

+ Activity Information +

+
+
+

Last Login

+

-

+
+
+

Created At

+

-

+
+
+

Last Updated

+

-

+
+
+
+ + +
+

+ Ownership & Memberships +

+
+
+
+ +

Companies Owned

+
+

+

+ Companies where this user is the owner +

+
+
+
+ +

Vendor Memberships

+
+

+

+ Vendors where this user is a team member +

+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/admin/user-edit.html b/app/templates/admin/user-edit.html new file mode 100644 index 00000000..03c79ad8 --- /dev/null +++ b/app/templates/admin/user-edit.html @@ -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 %} + +
+
+

+ Edit User +

+

+ @ +

+
+ + + Back to Users + +
+ + +
+ +

Loading user...

+
+ + +
+ +
+

+ Quick Actions +

+
+ + + +
+ + + + Active + + + Inactive + +
+
+
+ + +
+
+ +
+

+ Account Information +

+ + + + + + + + + + + + +
+ + +
+

+ Personal Information +

+ + + + + + + + + +
+
+ + + + + +
+ + Cancel + + +
+
+ + +
+

+ More Actions +

+
+ + + + View User + + + + +
+

+ + + User cannot be deleted while they own companies ( companies). + + + User can be deleted. + +

+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 7bc7e85a..8bff890a 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -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 %} -
- -
-
- -
-
- -
-
-
+ +
+

+ User Management +

+ + + Create User + +
- -
- - + +
+ +

Loading users...

+
- - + +
+ +
+

Error loading users

+

+
+
- - -
+ +
+ +
+
+ +
+
+

+ Total Users +

+

+ 0 +

- -
-
-
-
-

Total Users

-

-
-
-
+ +
+
+
- -
-
-
-

Active Users

-

-
-
-
-
- -
-
-
-

Vendors

-

-
-
-
-
- -
-
-
-

Admins

-

-
-
-
+
+

+ Active +

+

+ 0 +

- -
- -
-
-

Loading users...

+ +
+
+ +
+
+

+ Inactive +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Admins +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ +
+ +
+
- -
-
-

No users found

-
+ +
+ + - -
- - + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + +
UserEmailRoleStatusRegisteredLast LoginActions
+
+ + +
+ + + Showing - of + + + + + +
-
-
-
+ + + +
{% endblock %} {% block extra_scripts %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/models/schema/auth.py b/models/schema/auth.py index 16d16045..db0cf643 100644 --- a/models/schema/auth.py +++ b/models/schema/auth.py @@ -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 diff --git a/static/admin/js/user-create.js b/static/admin/js/user-create.js new file mode 100644 index 00000000..50b1e5a4 --- /dev/null +++ b/static/admin/js/user-create.js @@ -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'); diff --git a/static/admin/js/user-detail.js b/static/admin/js/user-detail.js new file mode 100644 index 00000000..8a8f8a44 --- /dev/null +++ b/static/admin/js/user-detail.js @@ -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'); diff --git a/static/admin/js/user-edit.js b/static/admin/js/user-edit.js new file mode 100644 index 00000000..9ab6fae5 --- /dev/null +++ b/static/admin/js/user-edit.js @@ -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'); diff --git a/static/admin/js/users.js b/static/admin/js/users.js index 0af178b6..c80ee52e 100644 --- a/static/admin/js/users.js +++ b/static/admin/js/users.js @@ -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'; } }; }