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:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user