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

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