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>
287 lines
11 KiB
HTML
287 lines
11 KiB
HTML
<!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> |