feat: Complete frontend-backend integration with JWT authentication

Implemented full JWT-based authentication integration between frontend and backend:

Frontend Changes:
- Updated login.html to store JWT tokens in localStorage after successful login
- Updated setup.html to use correct API payload format (master_password)
- Modified app.js and queue.js to include Bearer tokens in all authenticated requests
- Updated makeAuthenticatedRequest() to add Authorization header with JWT token
- Enhanced checkAuthentication() to verify token and redirect on 401 responses
- Updated logout() to clear tokens from localStorage

API Endpoint Updates:
- Mapped queue API endpoints to new backend structure
- /api/queue/clear → /api/queue/completed (DELETE) for clearing completed
- /api/queue/remove → /api/queue/{item_id} (DELETE) for single removal
- /api/queue/retry payload changed to {item_ids: []} array format
- /api/download/pause|resume|cancel → /api/queue/pause|resume|stop

Testing:
- Created test_frontend_integration_smoke.py with JWT token validation tests
- Verified login returns access_token, token_type, and expires_at
- Tested Bearer token authentication on protected endpoints
- Smoke tests passing for authentication flow

Documentation:
- Updated infrastructure.md with JWT authentication implementation details
- Documented token storage, API endpoint changes, and response formats
- Marked Frontend Integration task as completed in instructions.md
- Added frontend integration testing section

WebSocket:
- Verified WebSocket integration with new backend (already functional)
- Dual event handlers support both old and new message types
- Room-based subscriptions working correctly

This completes Task 7: Frontend Integration from the development instructions.
This commit is contained in:
2025-10-17 19:27:52 +02:00
parent 2bc616a062
commit 0957a6e183
8 changed files with 550 additions and 89 deletions

View File

@@ -40,10 +40,19 @@ class AniWorldApp {
}
try {
const response = await fetch('/api/auth/status');
// First check if we have a token
const token = localStorage.getItem('access_token');
// Build request with token if available
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/auth/status', { headers });
const data = await response.json();
if (!data.has_master_password) {
if (!data.configured) {
// No master password set, redirect to setup
window.location.href = '/setup';
return;
@@ -51,37 +60,58 @@ class AniWorldApp {
if (!data.authenticated) {
// Not authenticated, redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires_at');
window.location.href = '/login';
return;
}
// User is authenticated, show logout button if master password is set
if (data.has_master_password) {
document.getElementById('logout-btn').style.display = 'block';
// User is authenticated, show logout button
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.style.display = 'block';
}
} catch (error) {
console.error('Authentication check failed:', error);
// On error, assume we need to login
// On error, clear token and redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires_at');
window.location.href = '/login';
}
}
async logout() {
try {
const response = await fetch('/api/auth/logout', { method: 'POST' });
const data = await response.json();
const response = await this.makeAuthenticatedRequest('/api/auth/logout', { method: 'POST' });
// Clear tokens from localStorage
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires_at');
if (data.status === 'success') {
this.showToast('Logged out successfully', 'success');
setTimeout(() => {
window.location.href = '/login';
}, 1000);
if (response && response.ok) {
const data = await response.json();
if (data.status === 'ok') {
this.showToast('Logged out successfully', 'success');
} else {
this.showToast('Logged out', 'success');
}
} else {
this.showToast('Logout failed', 'error');
// Even if the API fails, we cleared the token locally
this.showToast('Logged out', 'success');
}
setTimeout(() => {
window.location.href = '/login';
}, 1000);
} catch (error) {
console.error('Logout error:', error);
this.showToast('Logout failed', 'error');
// Clear token even on error
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires_at');
this.showToast('Logged out', 'success');
setTimeout(() => {
window.location.href = '/login';
}, 1000);
}
}
@@ -534,15 +564,31 @@ class AniWorldApp {
}
async makeAuthenticatedRequest(url, options = {}) {
// Ensure credentials are included for session-based authentication
// Get JWT token from localStorage
const token = localStorage.getItem('access_token');
// Check if token exists
if (!token) {
window.location.href = '/login';
return null;
}
// Include Authorization header with Bearer token
const requestOptions = {
credentials: 'same-origin',
...options
...options,
headers: {
'Authorization': `Bearer ${token}`,
...options.headers
}
};
const response = await fetch(url, requestOptions);
if (response.status === 401) {
// Token is invalid or expired, clear it and redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires_at');
window.location.href = '/login';
return null;
}
@@ -1843,20 +1889,16 @@ class AniWorldApp {
if (!this.isDownloading || this.isPaused) return;
try {
const response = await this.makeAuthenticatedRequest('/api/download/pause', { method: 'POST' });
const response = await this.makeAuthenticatedRequest('/api/queue/pause', { method: 'POST' });
if (!response) return;
const data = await response.json();
if (data.status === 'success') {
document.getElementById('pause-download').classList.add('hidden');
document.getElementById('resume-download').classList.remove('hidden');
this.showToast('Download paused', 'warning');
} else {
this.showToast(`Pause failed: ${data.message}`, 'error');
}
document.getElementById('pause-download').classList.add('hidden');
document.getElementById('resume-download').classList.remove('hidden');
this.showToast('Queue paused', 'warning');
} catch (error) {
console.error('Pause error:', error);
this.showToast('Failed to pause download', 'error');
this.showToast('Failed to pause queue', 'error');
}
}
@@ -1864,40 +1906,32 @@ class AniWorldApp {
if (!this.isDownloading || !this.isPaused) return;
try {
const response = await this.makeAuthenticatedRequest('/api/download/resume', { method: 'POST' });
const response = await this.makeAuthenticatedRequest('/api/queue/resume', { method: 'POST' });
if (!response) return;
const data = await response.json();
if (data.status === 'success') {
document.getElementById('pause-download').classList.remove('hidden');
document.getElementById('resume-download').classList.add('hidden');
this.showToast('Download resumed', 'success');
} else {
this.showToast(`Resume failed: ${data.message}`, 'error');
}
document.getElementById('pause-download').classList.remove('hidden');
document.getElementById('resume-download').classList.add('hidden');
this.showToast('Queue resumed', 'success');
} catch (error) {
console.error('Resume error:', error);
this.showToast('Failed to resume download', 'error');
this.showToast('Failed to resume queue', 'error');
}
}
async cancelDownload() {
if (!this.isDownloading) return;
if (confirm('Are you sure you want to cancel the download?')) {
if (confirm('Are you sure you want to stop the download queue?')) {
try {
const response = await this.makeAuthenticatedRequest('/api/download/cancel', { method: 'POST' });
const response = await this.makeAuthenticatedRequest('/api/queue/stop', { method: 'POST' });
if (!response) return;
const data = await response.json();
if (data.status === 'success') {
this.showToast('Download cancelled', 'warning');
} else {
this.showToast(`Cancel failed: ${data.message}`, 'error');
}
this.showToast('Queue stopped', 'warning');
} catch (error) {
console.error('Cancel error:', error);
this.showToast('Failed to cancel download', 'error');
console.error('Stop error:', error);
this.showToast('Failed to stop queue', 'error');
}
}
}