Add comprehensive database integration, authentication, and admin dashboard
This commit introduces major enhancements to Talk2Me: ## Database Integration - PostgreSQL support with SQLAlchemy ORM - Redis integration for caching and real-time analytics - Automated database initialization scripts - Migration support infrastructure ## User Authentication System - JWT-based API authentication - Session-based web authentication - API key authentication for programmatic access - User roles and permissions (admin/user) - Login history and session tracking - Rate limiting per user with customizable limits ## Admin Dashboard - Real-time analytics and monitoring - User management interface (create, edit, delete users) - System health monitoring - Request/error tracking - Language pair usage statistics - Performance metrics visualization ## Key Features - Dual authentication support (token + user accounts) - Graceful fallback for missing services - Non-blocking analytics middleware - Comprehensive error handling - Session management with security features ## Bug Fixes - Fixed rate limiting bypass for admin routes - Added missing email validation method - Improved error handling for missing database tables - Fixed session-based authentication for API endpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
693
templates/admin_users.html
Normal file
693
templates/admin_users.html
Normal file
@@ -0,0 +1,693 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Management - Talk2Me Admin</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
<style>
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.action-buttons .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.stats-card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stats-card .card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.stats-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #4a5568;
|
||||
}
|
||||
.table-responsive {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.modal-header {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.search-filters {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/admin">Talk2Me Admin</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a class="nav-link" href="/" target="_blank">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Main App
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4">User Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Total Users</h6>
|
||||
<div class="stats-number" id="stat-total">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Active Users</h6>
|
||||
<div class="stats-number text-success" id="stat-active">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Suspended Users</h6>
|
||||
<div class="stats-number text-warning" id="stat-suspended">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Admin Users</h6>
|
||||
<div class="stats-number text-primary" id="stat-admins">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="search-filters">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search by email, username, or name...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="roleFilter">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="sortBy">
|
||||
<option value="created_at">Created Date</option>
|
||||
<option value="last_login_at">Last Login</option>
|
||||
<option value="total_requests">Total Requests</option>
|
||||
<option value="username">Username</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" onclick="createUser()">
|
||||
<i class="bi bi-plus-circle"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Usage</th>
|
||||
<th>Last Login</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- Users will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- Pagination will be loaded here -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- User Details Modal -->
|
||||
<div class="modal fade" id="userModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">User Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="userModalBody">
|
||||
<!-- User details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div class="modal fade" id="createUserModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="createUserModalTitle">Create User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<div class="mb-3">
|
||||
<label for="userEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="userEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userUsername" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="userUsername" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userPassword" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="userPassword" minlength="8">
|
||||
<small class="text-muted">Leave blank to keep existing password (edit mode)</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userFullName" class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="userFullName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userRole" class="form-label">Role</label>
|
||||
<select class="form-select" id="userRole">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="userVerified">
|
||||
<label class="form-check-label" for="userVerified">
|
||||
Email Verified
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userRateLimit" class="form-label">Rate Limit (per minute)</label>
|
||||
<input type="number" class="form-control" id="userRateLimit" value="30">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveUser()">Save User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let editingUserId = null;
|
||||
|
||||
// Configure axios defaults for session-based auth
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
// Also check if there's a JWT token (for API users)
|
||||
let authToken = localStorage.getItem('auth_token');
|
||||
if (authToken) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
// For admin token auth, add the admin token header
|
||||
const adminToken = '{{ session.get("admin_token", "") }}';
|
||||
if (adminToken) {
|
||||
axios.defaults.headers.common['X-Admin-Token'] = adminToken;
|
||||
}
|
||||
|
||||
// Load users on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadStats();
|
||||
loadUsers();
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(loadUsers, 300));
|
||||
document.getElementById('roleFilter').addEventListener('change', loadUsers);
|
||||
document.getElementById('statusFilter').addEventListener('change', loadUsers);
|
||||
document.getElementById('sortBy').addEventListener('change', loadUsers);
|
||||
});
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/admin/stats/users');
|
||||
const stats = response.data.stats;
|
||||
|
||||
document.getElementById('stat-total').textContent = stats.total_users;
|
||||
document.getElementById('stat-active').textContent = stats.active_users;
|
||||
document.getElementById('stat-suspended').textContent = stats.suspended_users;
|
||||
document.getElementById('stat-admins').textContent = stats.admin_users;
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers(page = 1) {
|
||||
try {
|
||||
currentPage = page;
|
||||
const params = {
|
||||
page: page,
|
||||
per_page: 20,
|
||||
search: document.getElementById('searchInput').value,
|
||||
role: document.getElementById('roleFilter').value,
|
||||
status: document.getElementById('statusFilter').value,
|
||||
sort_by: document.getElementById('sortBy').value,
|
||||
sort_order: 'desc'
|
||||
};
|
||||
|
||||
const response = await axios.get('/api/auth/admin/users', { params });
|
||||
const data = response.data;
|
||||
|
||||
displayUsers(data.users);
|
||||
displayPagination(data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
showAlert('Failed to load users', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function displayUsers(users) {
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
users.forEach(user => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
${user.avatar_url ?
|
||||
`<img src="${user.avatar_url}" class="user-avatar me-2" alt="${user.username}">` :
|
||||
`<div class="user-avatar me-2 bg-secondary d-flex align-items-center justify-content-center text-white">
|
||||
${user.username.charAt(0).toUpperCase()}
|
||||
</div>`
|
||||
}
|
||||
<div>
|
||||
<div class="fw-bold">${user.username}</div>
|
||||
<small class="text-muted">${user.email}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${user.role === 'admin' ? 'bg-primary' : 'bg-secondary'}">
|
||||
${user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${getStatusBadge(user)}
|
||||
</td>
|
||||
<td>
|
||||
<small>
|
||||
<i class="bi bi-translate"></i> ${user.total_translations}<br>
|
||||
<i class="bi bi-mic"></i> ${user.total_transcriptions}<br>
|
||||
<i class="bi bi-volume-up"></i> ${user.total_tts_requests}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<small>${user.last_login_at ? new Date(user.last_login_at).toLocaleString() : 'Never'}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small>${new Date(user.created_at).toLocaleDateString()}</small>
|
||||
</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-sm btn-info" onclick="viewUser('${user.id}')" title="View Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" onclick="editUser('${user.id}')" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
${user.is_suspended ?
|
||||
`<button class="btn btn-sm btn-success" onclick="unsuspendUser('${user.id}')" title="Unsuspend">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</button>` :
|
||||
`<button class="btn btn-sm btn-warning" onclick="suspendUser('${user.id}')" title="Suspend">
|
||||
<i class="bi bi-pause-circle"></i>
|
||||
</button>`
|
||||
}
|
||||
${user.role !== 'admin' ?
|
||||
`<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>` : ''
|
||||
}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadge(user) {
|
||||
if (user.is_suspended) {
|
||||
return '<span class="badge bg-warning status-badge">Suspended</span>';
|
||||
} else if (!user.is_active) {
|
||||
return '<span class="badge bg-secondary status-badge">Inactive</span>';
|
||||
} else if (!user.is_verified) {
|
||||
return '<span class="badge bg-info status-badge">Unverified</span>';
|
||||
} else {
|
||||
return '<span class="badge bg-success status-badge">Active</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayPagination(pagination) {
|
||||
const nav = document.getElementById('pagination');
|
||||
nav.innerHTML = '';
|
||||
|
||||
const totalPages = pagination.pages;
|
||||
const currentPage = pagination.page;
|
||||
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `<a class="page-link" href="#" onclick="loadUsers(${currentPage - 1})">Previous</a>`;
|
||||
nav.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
||||
li.innerHTML = `<a class="page-link" href="#" onclick="loadUsers(${i})">${i}</a>`;
|
||||
nav.appendChild(li);
|
||||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'page-item disabled';
|
||||
li.innerHTML = '<span class="page-link">...</span>';
|
||||
nav.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `<a class="page-link" href="#" onclick="loadUsers(${currentPage + 1})">Next</a>`;
|
||||
nav.appendChild(nextLi);
|
||||
}
|
||||
|
||||
async function viewUser(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/auth/admin/users/${userId}`);
|
||||
const data = response.data;
|
||||
|
||||
const modalBody = document.getElementById('userModalBody');
|
||||
modalBody.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>User Information</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Username:</dt>
|
||||
<dd class="col-sm-8">${data.user.username}</dd>
|
||||
<dt class="col-sm-4">Email:</dt>
|
||||
<dd class="col-sm-8">${data.user.email}</dd>
|
||||
<dt class="col-sm-4">Full Name:</dt>
|
||||
<dd class="col-sm-8">${data.user.full_name || 'N/A'}</dd>
|
||||
<dt class="col-sm-4">Role:</dt>
|
||||
<dd class="col-sm-8">${data.user.role}</dd>
|
||||
<dt class="col-sm-4">Status:</dt>
|
||||
<dd class="col-sm-8">${getStatusBadge(data.user)}</dd>
|
||||
<dt class="col-sm-4">API Key:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<code>${data.user.api_key}</code>
|
||||
<button class="btn btn-sm btn-secondary ms-2" onclick="copyToClipboard('${data.user.api_key}')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Usage Statistics</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6">Total Requests:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_requests}</dd>
|
||||
<dt class="col-sm-6">Translations:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_translations}</dd>
|
||||
<dt class="col-sm-6">Transcriptions:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_transcriptions}</dd>
|
||||
<dt class="col-sm-6">TTS Requests:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_tts_requests}</dd>
|
||||
<dt class="col-sm-6">Rate Limits:</dt>
|
||||
<dd class="col-sm-6">
|
||||
${data.user.rate_limit_per_minute}/min<br>
|
||||
${data.user.rate_limit_per_hour}/hour<br>
|
||||
${data.user.rate_limit_per_day}/day
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<h6>Login History</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>IP Address</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.login_history.map(login => `
|
||||
<tr>
|
||||
<td>${new Date(login.login_at).toLocaleString()}</td>
|
||||
<td>${login.ip_address}</td>
|
||||
<td>${login.login_method}</td>
|
||||
<td>${login.success ?
|
||||
'<span class="badge bg-success">Success</span>' :
|
||||
`<span class="badge bg-danger">Failed</span>`
|
||||
}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr>
|
||||
<h6>Active Sessions (${data.active_sessions.length})</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>Created</th>
|
||||
<th>Last Active</th>
|
||||
<th>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.active_sessions.map(session => `
|
||||
<tr>
|
||||
<td><code>${session.session_id.substr(0, 8)}...</code></td>
|
||||
<td>${new Date(session.created_at).toLocaleString()}</td>
|
||||
<td>${new Date(session.last_active_at).toLocaleString()}</td>
|
||||
<td>${session.ip_address}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('userModal'));
|
||||
modal.show();
|
||||
} catch (error) {
|
||||
console.error('Failed to load user details:', error);
|
||||
showAlert('Failed to load user details', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
editingUserId = null;
|
||||
document.getElementById('createUserModalTitle').textContent = 'Create User';
|
||||
document.getElementById('userForm').reset();
|
||||
document.getElementById('userPassword').required = true;
|
||||
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function editUser(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/auth/admin/users/${userId}`);
|
||||
const user = response.data.user;
|
||||
|
||||
editingUserId = userId;
|
||||
document.getElementById('createUserModalTitle').textContent = 'Edit User';
|
||||
document.getElementById('userEmail').value = user.email;
|
||||
document.getElementById('userUsername').value = user.username;
|
||||
document.getElementById('userPassword').value = '';
|
||||
document.getElementById('userPassword').required = false;
|
||||
document.getElementById('userFullName').value = user.full_name || '';
|
||||
document.getElementById('userRole').value = user.role;
|
||||
document.getElementById('userVerified').checked = user.is_verified;
|
||||
document.getElementById('userRateLimit').value = user.rate_limit_per_minute;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
||||
modal.show();
|
||||
} catch (error) {
|
||||
console.error('Failed to load user for editing:', error);
|
||||
showAlert('Failed to load user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
try {
|
||||
const data = {
|
||||
email: document.getElementById('userEmail').value,
|
||||
username: document.getElementById('userUsername').value,
|
||||
full_name: document.getElementById('userFullName').value,
|
||||
role: document.getElementById('userRole').value,
|
||||
is_verified: document.getElementById('userVerified').checked,
|
||||
rate_limit_per_minute: parseInt(document.getElementById('userRateLimit').value)
|
||||
};
|
||||
|
||||
if (editingUserId) {
|
||||
// Update existing user
|
||||
if (document.getElementById('userPassword').value) {
|
||||
data.password = document.getElementById('userPassword').value;
|
||||
}
|
||||
await axios.put(`/api/auth/admin/users/${editingUserId}`, data);
|
||||
showAlert('User updated successfully', 'success');
|
||||
} else {
|
||||
// Create new user
|
||||
data.password = document.getElementById('userPassword').value;
|
||||
await axios.post('/api/auth/admin/users', data);
|
||||
showAlert('User created successfully', 'success');
|
||||
}
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to save user:', error);
|
||||
showAlert(error.response?.data?.error || 'Failed to save user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function suspendUser(userId) {
|
||||
if (!confirm('Are you sure you want to suspend this user?')) return;
|
||||
|
||||
try {
|
||||
const reason = prompt('Enter suspension reason:');
|
||||
if (!reason) return;
|
||||
|
||||
await axios.post(`/api/auth/admin/users/${userId}/suspend`, { reason });
|
||||
showAlert('User suspended successfully', 'success');
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to suspend user:', error);
|
||||
showAlert('Failed to suspend user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function unsuspendUser(userId) {
|
||||
if (!confirm('Are you sure you want to unsuspend this user?')) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/api/auth/admin/users/${userId}/unsuspend`);
|
||||
showAlert('User unsuspended successfully', 'success');
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to unsuspend user:', error);
|
||||
showAlert('Failed to unsuspend user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/auth/admin/users/${userId}`);
|
||||
showAlert('User deleted successfully', 'success');
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
showAlert('Failed to delete user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showAlert('Copied to clipboard', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
|
||||
alertDiv.style.zIndex = '9999';
|
||||
alertDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alertDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
alertDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
287
templates/login.html
Normal file
287
templates/login.html
Normal file
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Talk2Me</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.login-header h1 {
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.login-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #5a67d8;
|
||||
border-color: #5a67d8;
|
||||
}
|
||||
.alert {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.divider {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.divider span {
|
||||
background: white;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.api-key-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.api-key-display {
|
||||
background: #f8f9fa;
|
||||
padding: 0.75rem;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
}
|
||||
.loading.show {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1><i class="bi bi-translate"></i> Talk2Me</h1>
|
||||
<p>Voice Translation Made Simple</p>
|
||||
</div>
|
||||
|
||||
<div id="alertContainer">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ error }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form id="loginForm" method="POST" action="{{ url_for('login', next=request.args.get('next')) }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Email or Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="loading spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<span class="btn-text">Sign In</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>OR</span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="mb-2">Use the app without signing in</p>
|
||||
<a href="/" class="btn btn-outline-secondary w-100">
|
||||
<i class="bi bi-arrow-right-circle"></i> Continue as Guest
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="api-key-section" id="apiKeySection" style="display: none;">
|
||||
<h6 class="mb-2">Your API Key</h6>
|
||||
<p class="text-muted small">Use this key to authenticate API requests:</p>
|
||||
<div class="api-key-display" id="apiKeyDisplay">
|
||||
<span id="apiKey"></span>
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" onclick="copyApiKey()">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="/" class="text-muted small">
|
||||
<i class="bi bi-arrow-left"></i> Back to App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// Check if already logged in
|
||||
const authToken = localStorage.getItem('auth_token');
|
||||
if (authToken) {
|
||||
// Verify token is still valid
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${authToken}`;
|
||||
axios.get('/api/auth/profile').then(response => {
|
||||
// Token is valid, redirect to app
|
||||
window.location.href = '/';
|
||||
}).catch(() => {
|
||||
// Token invalid, clear it
|
||||
localStorage.removeItem('auth_token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const loadingSpinner = document.querySelector('.loading');
|
||||
const btnText = document.querySelector('.btn-text');
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
|
||||
// Show loading state
|
||||
loadingSpinner.classList.add('show');
|
||||
btnText.textContent = 'Signing in...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', {
|
||||
username: username,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// Store token
|
||||
const token = response.data.tokens.access_token;
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('refresh_token', response.data.tokens.refresh_token);
|
||||
localStorage.setItem('user_id', response.data.user.id);
|
||||
localStorage.setItem('username', response.data.user.username);
|
||||
localStorage.setItem('user_role', response.data.user.role);
|
||||
|
||||
// Show API key
|
||||
document.getElementById('apiKey').textContent = response.data.user.api_key;
|
||||
document.getElementById('apiKeySection').style.display = 'block';
|
||||
|
||||
showAlert('Login successful! Redirecting...', 'success');
|
||||
|
||||
// Redirect based on role or next parameter
|
||||
setTimeout(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const nextUrl = urlParams.get('next');
|
||||
|
||||
if (nextUrl) {
|
||||
window.location.href = nextUrl;
|
||||
} else if (response.data.user.role === 'admin') {
|
||||
window.location.href = '/admin';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
const errorMessage = error.response?.data?.error || 'Login failed. Please try again.';
|
||||
showAlert(errorMessage, 'danger');
|
||||
|
||||
// Reset button state
|
||||
loadingSpinner.classList.remove('show');
|
||||
btnText.textContent = 'Sign In';
|
||||
submitBtn.disabled = false;
|
||||
|
||||
// Clear password field on error
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertContainer = document.getElementById('alertContainer');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
alertContainer.innerHTML = '';
|
||||
alertContainer.appendChild(alert);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
const apiKey = document.getElementById('apiKey').textContent;
|
||||
navigator.clipboard.writeText(apiKey).then(() => {
|
||||
showAlert('API key copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
showAlert('Failed to copy API key', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Enter key in form fields
|
||||
document.getElementById('username').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user