First steps for auth

This commit is contained in:
Xoconoch
2025-08-03 20:16:07 -06:00
parent dc878b26ac
commit 6ab603d90a
37 changed files with 2315 additions and 160 deletions

View File

@@ -16,4 +16,15 @@ PUID=1000
PGID=1000
# Optional: Sets the default file permissions for newly created files within the container.
UMASK=0022
UMASK=0022
# Auth
ENABLE_AUTH=true
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRATION_HOURS=24
# Default Admin User (created automatically if no users exist)
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123

280
AUTH_SETUP.md Normal file
View File

@@ -0,0 +1,280 @@
# Spotizerr Authentication System
## Overview
Spotizerr now includes a modern, JWT-based authentication system that can be enabled or disabled via environment variables. The system supports username/password authentication with **session persistence across browser refreshes** and is designed to be easily extensible for future SSO implementations.
## Features
- 🔐 **JWT-based authentication** with secure token management
- 👤 **User registration and login** with password validation
- 🛡️ **Role-based access control** (user/admin roles)
- 🎛️ **Environment-controlled** - easily enable/disable
- 📱 **Responsive UI** - beautiful login screen with dark mode support
- 🔄 **Auto token refresh** and secure logout
- 💾 **Session persistence** - remember me across browser restarts
- 🔗 **Multi-tab sync** - logout/login reflected across all tabs
- 🎨 **Seamless integration** - existing app works unchanged when auth is disabled
## Session Management
### Remember Me Functionality
The authentication system supports two types of sessions:
1. **Persistent Sessions** (Remember Me = ON)
- Token stored in `localStorage`
- Session survives browser restarts
- Green indicator in user menu
- Default option for better UX
2. **Session-Only** (Remember Me = OFF)
- Token stored in `sessionStorage`
- Session cleared when browser closes
- Orange indicator in user menu
- More secure for shared computers
### Session Restoration
- **Automatic**: Sessions are automatically restored on page refresh
- **Validation**: Stored tokens are validated against the server
- **Graceful Degradation**: Invalid/expired tokens are cleared automatically
- **Visual Feedback**: Loading screen shows "Restoring your session..."
### Multi-Tab Synchronization
- Login/logout actions are synced across all open tabs
- Uses browser `storage` events for real-time synchronization
- Prevents inconsistent authentication states
## Environment Configuration
### Enable Authentication
Set the following environment variables:
```bash
# Enable the authentication system
ENABLE_AUTH=true
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRATION_HOURS=24
# Default Admin User (created automatically if no users exist)
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123
```
### Disable Authentication
```bash
# Disable authentication (default)
ENABLE_AUTH=false
```
## Backend Dependencies
The following Python packages are required:
```
bcrypt==4.2.1
PyJWT==2.10.1
python-multipart==0.0.17
```
## Usage
### When Authentication is Enabled
1. **First Time Setup**: When enabled with no existing users, a default admin account is created
- Username: `admin` (or `DEFAULT_ADMIN_USERNAME`)
- Password: `admin123` (or `DEFAULT_ADMIN_PASSWORD`)
- **⚠️ Change the default password immediately!**
2. **User Registration**: First user to register becomes an admin, subsequent users are regular users
3. **Login Screen**: Users see a beautiful login/registration form
- Username/password login
- **Remember Me checkbox** with session type indicator
- Optional email field for registration
- Form validation and error handling
- Responsive design with dark mode support
4. **Session Indicators**: Users can see their session type
- **Green dot**: Persistent session (survives browser restart)
- **Orange dot**: Session-only (cleared when browser closes)
- Tooltip and dropdown show session details
5. **User Management**: Admin users can:
- View all users
- Delete users (except themselves)
- Change user roles
- Access config and credential management
### When Authentication is Disabled
- **No Changes**: App works exactly as before
- **Full Access**: All features available without login
- **No UI Changes**: No login screens or user menus
## Session Storage Details
### Token Storage Locations
```javascript
// Persistent sessions (Remember Me = true)
localStorage.setItem("auth_token", token);
localStorage.setItem("auth_remember", "true");
// Session-only (Remember Me = false)
sessionStorage.setItem("auth_token", token);
// No localStorage entries
```
### Session Validation Flow
1. **App Start**: Check for stored token in localStorage → sessionStorage
2. **Token Found**: Validate token with `/api/auth/status`
3. **Valid Token**: Restore user session automatically
4. **Invalid Token**: Clear storage, show login screen
5. **No Token**: Show login screen (if auth enabled)
## API Endpoints
### Authentication Endpoints
```
GET /api/auth/status # Check auth status & validate token
POST /api/auth/login # User login
POST /api/auth/register # User registration
POST /api/auth/logout # User logout
GET /api/auth/profile # Get current user profile
PUT /api/auth/profile/password # Change password
```
### Admin Endpoints
```
GET /api/auth/users # List all users
DELETE /api/auth/users/{username} # Delete user
PUT /api/auth/users/{username}/role # Update user role
```
## Protected Routes
When authentication is enabled, the following routes require authentication:
- `/api/config/*` - Configuration management
- `/api/credentials/*` - Credential management
- `/api/auth/users/*` - User management (admin only)
- `/api/auth/profile/*` - Profile management
## Frontend Components
### LoginScreen
- Modern, responsive login/registration form
- **Remember Me checkbox** with visual indicators
- Client-side validation
- Smooth animations and transitions
- Dark mode support
### UserMenu
- Shows current user info
- **Session type indicator** (persistent/session-only)
- Dropdown with logout option
- Role indicator (admin/user)
### ProtectedRoute
- Wraps the entire app
- **Enhanced loading screen** with session restoration feedback
- Shows login screen when needed
- Handles loading states
## Security Features
- **Password Hashing**: bcrypt with salt
- **JWT Tokens**: Secure, expiring tokens
- **Token Validation**: Server-side validation on every request
- **Secure Storage**: Appropriate storage selection (localStorage vs sessionStorage)
- **HTTPS Ready**: Designed for production use
- **Input Validation**: Client and server-side validation
- **CSRF Protection**: Token-based authentication
- **Role-based Access**: Admin vs user permissions
- **Session Isolation**: Clear separation between persistent and session-only
## Development
### Adding New Protected Routes
```python
# Backend - Add to AuthMiddleware protected_paths
protected_paths = [
"/api/config",
"/api/auth/users",
"/api/your-new-route", # Add here
]
```
### Frontend Authentication Hooks
```typescript
import { useAuth } from "@/contexts/auth-context";
function MyComponent() {
const { user, isAuthenticated, logout, isRemembered } = useAuth();
if (!isAuthenticated) {
return <div>Please log in</div>;
}
const sessionType = isRemembered() ? "persistent" : "session-only";
return (
<div>
Hello, {user.username}! ({sessionType} session)
</div>
);
}
```
### Session Management
```typescript
// Login with remember preference
await login({ username, password }, rememberMe);
// Check session type
const isPersistent = isRemembered();
// Manual session restoration
await checkAuthStatus();
```
## Future Extensibility
The authentication system is designed to easily support:
- **OAuth/SSO Integration** (Google, GitHub, etc.)
- **LDAP/Active Directory**
- **Multi-factor Authentication**
- **API Key Authentication**
- **Refresh Token Rotation**
- **Session Management Dashboard**
## Production Deployment
1. **Change Default Credentials**: Update `DEFAULT_ADMIN_PASSWORD`
2. **Secure JWT Secret**: Use a strong, unique `JWT_SECRET`
3. **HTTPS**: Enable HTTPS in production
4. **Environment Variables**: Use secure environment variable management
5. **Database**: Consider migrating to a proper database for user storage
6. **Session Security**: Consider shorter token expiration for high-security environments
## Troubleshooting
### Common Issues
1. **"Authentication Required" errors**: Check `ENABLE_AUTH` setting
2. **Token expired**: Tokens expire after `JWT_EXPIRATION_HOURS`
3. **Session not persisting**: Check if "Remember Me" was enabled during login
4. **Can't access admin features**: Ensure user has admin role
5. **Login screen not showing**: Check if auth is enabled and user is logged out
6. **Session lost on refresh**: Check browser storage and token validation
### Debug Authentication
```bash
# Check auth status
curl -X GET http://localhost:7171/api/auth/status
# Test login
curl -X POST http://localhost:7171/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# Check browser storage
localStorage.getItem("auth_token")
localStorage.getItem("auth_remember")
sessionStorage.getItem("auth_token")
```
### Session Debugging
- **Browser Console**: Authentication system logs session restoration details
- **Network Tab**: Check `/api/auth/status` calls during app initialization
- **Application Tab**: Inspect localStorage/sessionStorage for token presence
- **Session Indicators**: Green/orange dots show current session type

13
app.py
View File

@@ -16,6 +16,7 @@ from urllib.parse import urlparse
# Import route routers (to be created)
from routes.auth.credentials import router as credentials_router
from routes.auth.auth import router as auth_router
from routes.content.artist import router as artist_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
@@ -30,6 +31,10 @@ from routes.system.config import router as config_router
from routes.utils.celery_manager import celery_manager
from routes.utils.celery_config import REDIS_URL
# Import authentication system
from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware
# Import and initialize routes (this will start the watch manager)
import routes
@@ -175,7 +180,15 @@ def create_app():
allow_headers=["*"],
)
# Add authentication middleware (only if auth is enabled)
if AUTH_ENABLED:
app.add_middleware(AuthMiddleware)
logging.info("Authentication system enabled")
else:
logging.info("Authentication system disabled")
# Register routers with URL prefixes
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(config_router, prefix="/api", tags=["config"])
app.include_router(search_router, prefix="/api", tags=["search"])
app.include_router(credentials_router, prefix="/api/credentials", tags=["credentials"])

View File

@@ -2,4 +2,7 @@ fastapi==0.115.6
uvicorn[standard]==0.32.1
celery==5.5.3
deezspot-spotizerr==2.2.2
httpx==0.28.1
httpx==0.28.1
bcrypt==4.2.1
PyJWT==2.10.1
python-multipart==0.0.17

View File

@@ -0,0 +1,221 @@
import os
import json
import bcrypt
import jwt
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
# Configuration
AUTH_ENABLED = os.getenv("ENABLE_AUTH", "false").lower() in ("true", "1", "yes", "on")
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", "24"))
# Paths
USERS_DIR = Path("./data/users")
USERS_FILE = USERS_DIR / "users.json"
class User:
def __init__(self, username: str, email: str = None, role: str = "user", created_at: str = None, last_login: str = None):
self.username = username
self.email = email
self.role = role
self.created_at = created_at or datetime.utcnow().isoformat()
self.last_login = last_login
def to_dict(self) -> Dict[str, Any]:
return {
"username": self.username,
"email": self.email,
"role": self.role,
"created_at": self.created_at,
"last_login": self.last_login
}
def to_public_dict(self) -> Dict[str, Any]:
"""Return user data without sensitive information"""
return {
"username": self.username,
"email": self.email,
"role": self.role,
"created_at": self.created_at,
"last_login": self.last_login
}
class UserManager:
def __init__(self):
self.ensure_users_file()
def ensure_users_file(self):
"""Ensure users directory and file exist"""
USERS_DIR.mkdir(parents=True, exist_ok=True)
if not USERS_FILE.exists():
with open(USERS_FILE, 'w') as f:
json.dump({}, f, indent=2)
logger.info(f"Created users file at {USERS_FILE}")
def load_users(self) -> Dict[str, Dict]:
"""Load users from file"""
try:
with open(USERS_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading users: {e}")
return {}
def save_users(self, users: Dict[str, Dict]):
"""Save users to file"""
try:
with open(USERS_FILE, 'w') as f:
json.dump(users, f, indent=2)
except Exception as e:
logger.error(f"Error saving users: {e}")
raise
def hash_password(self, password: str) -> str:
"""Hash password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(self, password: str, hashed: str) -> bool:
"""Verify password against hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
def create_user(self, username: str, password: str, email: str = None, role: str = "user") -> tuple[bool, str]:
"""Create a new user"""
users = self.load_users()
if username in users:
return False, "Username already exists"
hashed_password = self.hash_password(password)
user = User(username=username, email=email, role=role)
users[username] = {
**user.to_dict(),
"password_hash": hashed_password
}
self.save_users(users)
logger.info(f"Created user: {username}")
return True, "User created successfully"
def authenticate_user(self, username: str, password: str) -> Optional[User]:
"""Authenticate user and return User object if successful"""
users = self.load_users()
if username not in users:
return None
user_data = users[username]
if not self.verify_password(password, user_data["password_hash"]):
return None
# Update last login
user_data["last_login"] = datetime.utcnow().isoformat()
users[username] = user_data
self.save_users(users)
return User(**{k: v for k, v in user_data.items() if k != "password_hash"})
def get_user(self, username: str) -> Optional[User]:
"""Get user by username"""
users = self.load_users()
if username not in users:
return None
user_data = users[username]
return User(**{k: v for k, v in user_data.items() if k != "password_hash"})
def list_users(self) -> list[User]:
"""List all users"""
users = self.load_users()
return [User(**{k: v for k, v in user_data.items() if k != "password_hash"})
for user_data in users.values()]
def delete_user(self, username: str) -> tuple[bool, str]:
"""Delete a user"""
users = self.load_users()
if username not in users:
return False, "User not found"
del users[username]
self.save_users(users)
logger.info(f"Deleted user: {username}")
return True, "User deleted successfully"
def update_user_role(self, username: str, role: str) -> tuple[bool, str]:
"""Update user role"""
users = self.load_users()
if username not in users:
return False, "User not found"
users[username]["role"] = role
self.save_users(users)
logger.info(f"Updated role for user {username} to {role}")
return True, "User role updated successfully"
class TokenManager:
@staticmethod
def create_token(user: User) -> str:
"""Create JWT token for user"""
payload = {
"username": user.username,
"role": user.role,
"exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS),
"iat": datetime.utcnow()
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
@staticmethod
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""Verify JWT token and return payload"""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# Global instances
user_manager = UserManager()
token_manager = TokenManager()
def create_default_admin():
"""Create default admin user if no users exist"""
if not AUTH_ENABLED:
return
users = user_manager.load_users()
if not users:
default_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
default_password = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123")
success, message = user_manager.create_user(
username=default_username,
password=default_password,
role="admin"
)
if success:
logger.info(f"Created default admin user: {default_username}")
logger.warning(f"Default admin password is: {default_password}")
logger.warning("Please change the default admin password immediately!")
else:
logger.error(f"Failed to create default admin: {message}")
# Initialize default admin on import
create_default_admin()

254
routes/auth/auth.py Normal file
View File

@@ -0,0 +1,254 @@
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional, List
import logging
from . import AUTH_ENABLED, user_manager, token_manager, User
logger = logging.getLogger(__name__)
router = APIRouter()
security = HTTPBearer(auto_error=False)
# Pydantic models for request/response
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
class UserResponse(BaseModel):
username: str
email: Optional[str]
role: str
created_at: str
last_login: Optional[str]
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserResponse
class MessageResponse(BaseModel):
message: str
class AuthStatusResponse(BaseModel):
auth_enabled: bool
authenticated: bool = False
user: Optional[UserResponse] = None
# Dependency to get current user
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> Optional[User]:
"""Get current user from JWT token"""
if not AUTH_ENABLED:
# When auth is disabled, return a mock admin user
return User(username="system", role="admin")
if not credentials:
return None
payload = token_manager.verify_token(credentials.credentials)
if not payload:
return None
user = user_manager.get_user(payload["username"])
return user
async def require_auth(current_user: User = Depends(get_current_user)) -> User:
"""Require authentication - raises HTTPException if not authenticated"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
if not current_user:
raise HTTPException(
status_code=401,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
async def require_admin(current_user: User = Depends(require_auth)) -> User:
"""Require admin role - raises HTTPException if not admin"""
if current_user.role != "admin":
raise HTTPException(
status_code=403,
detail="Admin access required"
)
return current_user
# Authentication endpoints
@router.get("/status", response_model=AuthStatusResponse)
async def auth_status(current_user: Optional[User] = Depends(get_current_user)):
"""Get authentication status"""
return AuthStatusResponse(
auth_enabled=AUTH_ENABLED,
authenticated=current_user is not None,
user=UserResponse(**current_user.to_public_dict()) if current_user else None
)
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""Authenticate user and return access token"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
user = user_manager.authenticate_user(request.username, request.password)
if not user:
raise HTTPException(
status_code=401,
detail="Invalid username or password"
)
access_token = token_manager.create_token(user)
return LoginResponse(
access_token=access_token,
user=UserResponse(**user.to_public_dict())
)
@router.post("/register", response_model=MessageResponse)
async def register(request: RegisterRequest):
"""Register a new user"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
# Check if this is the first user (should be admin)
existing_users = user_manager.list_users()
role = "admin" if len(existing_users) == 0 else "user"
success, message = user_manager.create_user(
username=request.username,
password=request.password,
email=request.email,
role=role
)
if not success:
raise HTTPException(status_code=400, detail=message)
return MessageResponse(message=message)
@router.post("/logout", response_model=MessageResponse)
async def logout():
"""Logout user (client should delete token)"""
return MessageResponse(message="Logged out successfully")
# User management endpoints (admin only)
@router.get("/users", response_model=List[UserResponse])
async def list_users(current_user: User = Depends(require_admin)):
"""List all users (admin only)"""
users = user_manager.list_users()
return [UserResponse(**user.to_public_dict()) for user in users]
@router.delete("/users/{username}", response_model=MessageResponse)
async def delete_user(username: str, current_user: User = Depends(require_admin)):
"""Delete a user (admin only)"""
if username == current_user.username:
raise HTTPException(
status_code=400,
detail="Cannot delete your own account"
)
success, message = user_manager.delete_user(username)
if not success:
raise HTTPException(status_code=404, detail=message)
return MessageResponse(message=message)
@router.put("/users/{username}/role", response_model=MessageResponse)
async def update_user_role(
username: str,
role: str,
current_user: User = Depends(require_admin)
):
"""Update user role (admin only)"""
if role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail="Role must be 'user' or 'admin'"
)
if username == current_user.username:
raise HTTPException(
status_code=400,
detail="Cannot change your own role"
)
success, message = user_manager.update_user_role(username, role)
if not success:
raise HTTPException(status_code=404, detail=message)
return MessageResponse(message=message)
# Profile endpoints
@router.get("/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(require_auth)):
"""Get current user profile"""
return UserResponse(**current_user.to_public_dict())
@router.put("/profile/password", response_model=MessageResponse)
async def change_password(
current_password: str,
new_password: str,
current_user: User = Depends(require_auth)
):
"""Change current user's password"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
# Verify current password
authenticated_user = user_manager.authenticate_user(
current_user.username,
current_password
)
if not authenticated_user:
raise HTTPException(
status_code=401,
detail="Current password is incorrect"
)
# Update password (we need to load users, update, and save)
users = user_manager.load_users()
if current_user.username not in users:
raise HTTPException(status_code=404, detail="User not found")
users[current_user.username]["password_hash"] = user_manager.hash_password(new_password)
user_manager.save_users(users)
logger.info(f"Password changed for user: {current_user.username}")
return MessageResponse(message="Password changed successfully")

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
import json
import logging
from routes.utils.credentials import (
@@ -13,6 +13,9 @@ from routes.utils.credentials import (
save_global_spotify_api_creds,
)
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -22,7 +25,7 @@ init_credentials_db()
@router.get("/spotify_api_config")
@router.put("/spotify_api_config")
async def handle_spotify_api_config(request: Request):
async def handle_spotify_api_config(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Handles GET and PUT requests for the global Spotify API client_id and client_secret."""
try:
if request.method == "GET":
@@ -70,7 +73,7 @@ async def handle_spotify_api_config(request: Request):
@router.get("/{service}")
async def handle_list_credentials(service: str):
async def handle_list_credentials(service: str, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
@@ -86,7 +89,7 @@ async def handle_list_credentials(service: str):
@router.get("/{service}/{name}")
async def handle_get_credential(service: str, name: str):
async def handle_get_credential(service: str, name: str, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
@@ -110,7 +113,7 @@ async def handle_get_credential(service: str, name: str):
@router.post("/{service}/{name}")
async def handle_create_credential(service: str, name: str, request: Request):
async def handle_create_credential(service: str, name: str, request: Request, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
@@ -144,7 +147,7 @@ async def handle_create_credential(service: str, name: str, request: Request):
@router.put("/{service}/{name}")
async def handle_update_credential(service: str, name: str, request: Request):
async def handle_update_credential(service: str, name: str, request: Request, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
@@ -177,7 +180,7 @@ async def handle_update_credential(service: str, name: str, request: Request):
@router.delete("/{service}/{name}")
async def handle_delete_credential(service: str, name: str):
async def handle_delete_credential(service: str, name: str, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
@@ -208,7 +211,7 @@ async def handle_delete_credential(service: str, name: str):
@router.get("/all/{service}")
async def handle_all_credentials(service: str):
async def handle_all_credentials(service: str, current_user: User = Depends(require_admin_from_state)):
"""Lists all credentials for a given service. For Spotify, API keys are global and not listed per account."""
try:
if service not in ["spotify", "deezer"]:
@@ -250,7 +253,7 @@ async def handle_all_credentials(service: str):
@router.get("/markets")
async def handle_markets():
async def handle_markets(current_user: User = Depends(require_admin_from_state)):
"""
Returns a list of unique market regions for Deezer and Spotify accounts.
"""

177
routes/auth/middleware.py Normal file
View File

@@ -0,0 +1,177 @@
from fastapi import HTTPException, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from typing import Callable, List, Optional
import logging
from . import AUTH_ENABLED, token_manager, user_manager, User
logger = logging.getLogger(__name__)
class AuthMiddleware(BaseHTTPMiddleware):
"""
Authentication middleware that enforces strict access control.
Philosophy:
- Nothing should be accessible to non-users (except auth endpoints)
- Everything but config/credentials should be accessible to users
- Everything should be accessible to admins
"""
def __init__(
self,
app,
protected_paths: Optional[List[str]] = None,
public_paths: Optional[List[str]] = None
):
super().__init__(app)
# Minimal public paths - only auth-related endpoints and static assets
self.public_paths = public_paths or [
"/api/auth/status",
"/api/auth/login",
"/api/auth/register",
"/api/auth/logout",
"/static",
"/favicon.ico"
]
# Admin-only paths (sensitive operations)
self.admin_only_paths = [
"/api/credentials", # All credential management
"/api/config", # All configuration management
]
# All other /api paths require at least user authentication
# This will be enforced in the dispatch method
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""Process request with strict authentication"""
# If auth is disabled, allow all requests
if not AUTH_ENABLED:
return await call_next(request)
path = request.url.path
# Check if path is public (always allow)
if self._is_public_path(path):
return await call_next(request)
# For all other /api paths, require authentication
if path.startswith("/api"):
auth_result = await self._authenticate_request(request)
if not auth_result:
return JSONResponse(
status_code=401,
content={
"detail": "Authentication required",
"auth_enabled": True
},
headers={"WWW-Authenticate": "Bearer"}
)
# Check if admin access is required
if self._requires_admin_access(path):
if auth_result.role != "admin":
return JSONResponse(
status_code=403,
content={
"detail": "Admin access required"
}
)
# Add user to request state for use in route handlers
request.state.current_user = auth_result
return await call_next(request)
def _is_public_path(self, path: str) -> bool:
"""Check if path is in public paths list"""
# Special case for exact root path
if path == "/":
return True
for public_path in self.public_paths:
if path.startswith(public_path):
return True
return False
def _requires_admin_access(self, path: str) -> bool:
"""Check if path requires admin role"""
for admin_path in self.admin_only_paths:
if path.startswith(admin_path):
return True
return False
async def _authenticate_request(self, request: Request) -> Optional[User]:
"""Authenticate request and return user if valid"""
try:
token = None
# First try to get token from authorization header
authorization = request.headers.get("authorization")
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ", 1)[1]
# If no header token and this is an SSE endpoint, check query parameters
if not token and request.url.path.endswith("/stream"):
token = request.query_params.get("token")
if not token:
return None
# Verify token
payload = token_manager.verify_token(token)
if not payload:
return None
# Get user from payload
user = user_manager.get_user(payload["username"])
return user
except Exception as e:
logger.error(f"Authentication error: {e}")
return None
# Dependency function to get current user from request state
async def get_current_user_from_state(request: Request) -> Optional[User]:
"""Get current user from request state (set by middleware)"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
return getattr(request.state, 'current_user', None)
# Dependency function to require authentication
async def require_auth_from_state(request: Request) -> User:
"""Require authentication using request state"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
user = getattr(request.state, 'current_user', None)
if not user:
raise HTTPException(
status_code=401,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"}
)
return user
# Dependency function to require admin role
async def require_admin_from_state(request: Request) -> User:
"""Require admin role using request state"""
user = await require_auth_from_state(request)
if user.role != "admin":
raise HTTPException(
status_code=403,
detail="Admin access required"
)
return user

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
@@ -9,6 +9,9 @@ from routes.utils.celery_tasks import store_task_info, store_task_status, Progre
from routes.utils.get_info import get_spotify_info
from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
router = APIRouter()
@@ -18,7 +21,7 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{album_id}")
async def handle_download(album_id: str, request: Request):
async def handle_download(album_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
# Retrieve essential parameters from the request.
# name = request.args.get('name')
# artist = request.args.get('artist')
@@ -122,7 +125,7 @@ async def handle_download(album_id: str, request: Request):
@router.get("/download/cancel")
async def cancel_download(request: Request):
async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Cancel a running download process by its task id.
"""
@@ -141,7 +144,7 @@ async def cancel_download(request: Request):
@router.get("/info")
async def get_album_info(request: Request):
async def get_album_info(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve Spotify album metadata given a Spotify album ID.
Expects a query parameter 'id' that contains the Spotify album ID.

View File

@@ -2,7 +2,7 @@
Artist endpoint router.
"""
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
@@ -23,6 +23,9 @@ from routes.utils.watch.db import (
from routes.utils.watch.manager import check_watched_artists, get_watch_config
from routes.utils.get_info import get_spotify_info
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User
router = APIRouter()
# Existing log_json can be used, or a logger instance.
@@ -40,7 +43,7 @@ def log_json(message_dict):
@router.get("/download/{artist_id}")
async def handle_artist_download(artist_id: str, request: Request):
async def handle_artist_download(artist_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Enqueues album download tasks for the given artist.
Expected query parameters:
@@ -108,7 +111,7 @@ async def cancel_artist_download():
@router.get("/info")
async def get_artist_info(request: Request):
async def get_artist_info(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieves Spotify artist metadata given a Spotify artist ID.
Expects a query parameter 'id' with the Spotify artist ID.
@@ -166,7 +169,7 @@ async def get_artist_info(request: Request):
@router.put("/watch/{artist_spotify_id}")
async def add_artist_to_watchlist(artist_spotify_id: str):
async def add_artist_to_watchlist(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Adds an artist to the watchlist."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -233,7 +236,7 @@ async def add_artist_to_watchlist(artist_spotify_id: str):
@router.get("/watch/{artist_spotify_id}/status")
async def get_artist_watch_status(artist_spotify_id: str):
async def get_artist_watch_status(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Checks if a specific artist is being watched."""
logger.info(f"Checking watch status for artist {artist_spotify_id}.")
try:
@@ -251,7 +254,7 @@ async def get_artist_watch_status(artist_spotify_id: str):
@router.delete("/watch/{artist_spotify_id}")
async def remove_artist_from_watchlist(artist_spotify_id: str):
async def remove_artist_from_watchlist(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Removes an artist from the watchlist."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -282,7 +285,7 @@ async def remove_artist_from_watchlist(artist_spotify_id: str):
@router.get("/watch/list")
async def list_watched_artists_endpoint():
async def list_watched_artists_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Lists all artists currently in the watchlist."""
try:
artists = get_watched_artists()
@@ -293,7 +296,7 @@ async def list_watched_artists_endpoint():
@router.post("/watch/trigger_check")
async def trigger_artist_check_endpoint():
async def trigger_artist_check_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the artist checking mechanism for all watched artists."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -322,7 +325,7 @@ async def trigger_artist_check_endpoint():
@router.post("/watch/trigger_check/{artist_spotify_id}")
async def trigger_specific_artist_check_endpoint(artist_spotify_id: str):
async def trigger_specific_artist_check_endpoint(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the artist checking mechanism for a specific artist."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -375,7 +378,7 @@ async def trigger_specific_artist_check_endpoint(artist_spotify_id: str):
@router.post("/watch/{artist_spotify_id}/albums")
async def mark_albums_as_known_for_artist(artist_spotify_id: str, request: Request):
async def mark_albums_as_known_for_artist(artist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Fetches details for given album IDs and adds/updates them in the artist's local DB table."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -447,7 +450,7 @@ async def mark_albums_as_known_for_artist(artist_spotify_id: str, request: Reque
@router.delete("/watch/{artist_spotify_id}/albums")
async def mark_albums_as_missing_locally_for_artist(artist_spotify_id: str, request: Request):
async def mark_albums_as_missing_locally_for_artist(artist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Removes specified albums from the artist's local DB table."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
@@ -30,6 +30,9 @@ from routes.utils.watch.manager import (
) # For manual trigger & config
from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User
logger = logging.getLogger(__name__) # Added logger initialization
router = APIRouter()
@@ -40,7 +43,7 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{playlist_id}")
async def handle_download(playlist_id: str, request: Request):
async def handle_download(playlist_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
# Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed
@@ -142,7 +145,7 @@ async def handle_download(playlist_id: str, request: Request):
@router.get("/download/cancel")
async def cancel_download(request: Request):
async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Cancel a running playlist download process by its task id.
"""
@@ -161,7 +164,7 @@ async def cancel_download(request: Request):
@router.get("/info")
async def get_playlist_info(request: Request):
async def get_playlist_info(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve Spotify playlist metadata given a Spotify playlist ID.
Expects a query parameter 'id' that contains the Spotify playlist ID.
@@ -208,7 +211,7 @@ async def get_playlist_info(request: Request):
@router.get("/metadata")
async def get_playlist_metadata(request: Request):
async def get_playlist_metadata(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve only Spotify playlist metadata (no tracks) to avoid rate limiting.
Expects a query parameter 'id' that contains the Spotify playlist ID.
@@ -235,7 +238,7 @@ async def get_playlist_metadata(request: Request):
@router.get("/tracks")
async def get_playlist_tracks(request: Request):
async def get_playlist_tracks(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve playlist tracks with pagination support for progressive loading.
Expects query parameters: 'id' (playlist ID), 'limit' (optional), 'offset' (optional).
@@ -264,7 +267,7 @@ async def get_playlist_tracks(request: Request):
@router.put("/watch/{playlist_spotify_id}")
async def add_to_watchlist(playlist_spotify_id: str):
async def add_to_watchlist(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Adds a playlist to the watchlist."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -317,7 +320,7 @@ async def add_to_watchlist(playlist_spotify_id: str):
@router.get("/watch/{playlist_spotify_id}/status")
async def get_playlist_watch_status(playlist_spotify_id: str):
async def get_playlist_watch_status(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Checks if a specific playlist is being watched."""
logger.info(f"Checking watch status for playlist {playlist_spotify_id}.")
try:
@@ -337,7 +340,7 @@ async def get_playlist_watch_status(playlist_spotify_id: str):
@router.delete("/watch/{playlist_spotify_id}")
async def remove_from_watchlist(playlist_spotify_id: str):
async def remove_from_watchlist(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Removes a playlist from the watchlist."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -370,7 +373,7 @@ async def remove_from_watchlist(playlist_spotify_id: str):
@router.post("/watch/{playlist_spotify_id}/tracks")
async def mark_tracks_as_known(playlist_spotify_id: str, request: Request):
async def mark_tracks_as_known(playlist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Fetches details for given track IDs and adds/updates them in the playlist's local DB table."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -443,7 +446,7 @@ async def mark_tracks_as_known(playlist_spotify_id: str, request: Request):
@router.delete("/watch/{playlist_spotify_id}/tracks")
async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Request):
async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Removes specified tracks from the playlist's local DB table."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -495,7 +498,7 @@ async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Requ
@router.get("/watch/list")
async def list_watched_playlists_endpoint():
async def list_watched_playlists_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Lists all playlists currently in the watchlist."""
try:
playlists = get_watched_playlists()
@@ -506,7 +509,7 @@ async def list_watched_playlists_endpoint():
@router.post("/watch/trigger_check")
async def trigger_playlist_check_endpoint():
async def trigger_playlist_check_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the playlist checking mechanism for all watched playlists."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):
@@ -536,7 +539,7 @@ async def trigger_playlist_check_endpoint():
@router.post("/watch/trigger_check/{playlist_spotify_id}")
async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str):
async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the playlist checking mechanism for a specific playlist."""
watch_config = get_watch_config()
if not watch_config.get("enabled", False):

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
@@ -9,6 +9,9 @@ from routes.utils.celery_tasks import store_task_info, store_task_status, Progre
from routes.utils.get_info import get_spotify_info
from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
router = APIRouter()
@@ -18,7 +21,7 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{track_id}")
async def handle_download(track_id: str, request: Request):
async def handle_download(track_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
# Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed
@@ -122,7 +125,7 @@ async def handle_download(track_id: str, request: Request):
@router.get("/download/cancel")
async def cancel_download(request: Request):
async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Cancel a running download process by its task id.
"""
@@ -141,7 +144,7 @@ async def cancel_download(request: Request):
@router.get("/info")
async def get_track_info(request: Request):
async def get_track_info(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve Spotify track metadata given a Spotify track ID.
Expects a query parameter 'id' that contains the Spotify track ID.

View File

@@ -1,17 +1,20 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
import logging
from routes.utils.history_manager import history_manager
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/")
async def get_history(request: Request):
async def get_history(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve download history with optional filtering and pagination.
@@ -88,7 +91,7 @@ async def get_history(request: Request):
@router.get("/{task_id}")
async def get_download_by_task_id(task_id: str):
async def get_download_by_task_id(task_id: str, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve specific download history by task ID.
@@ -118,7 +121,7 @@ async def get_download_by_task_id(task_id: str):
@router.get("/{task_id}/children")
async def get_download_children(task_id: str):
async def get_download_children(task_id: str, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve children tracks for an album or playlist download.
@@ -168,7 +171,7 @@ async def get_download_children(task_id: str):
@router.get("/stats")
async def get_download_stats():
async def get_download_stats(current_user: User = Depends(require_auth_from_state)):
"""
Get download statistics and summary information.
"""
@@ -189,7 +192,7 @@ async def get_download_stats():
@router.get("/search")
async def search_history(request: Request):
async def search_history(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Search download history by title or artist.
@@ -236,7 +239,7 @@ async def search_history(request: Request):
@router.get("/recent")
async def get_recent_downloads(request: Request):
async def get_recent_downloads(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Get most recent downloads.
@@ -273,7 +276,7 @@ async def get_recent_downloads(request: Request):
@router.get("/failed")
async def get_failed_downloads(request: Request):
async def get_failed_downloads(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Get failed downloads.
@@ -310,7 +313,7 @@ async def get_failed_downloads(request: Request):
@router.post("/cleanup")
async def cleanup_old_history(request: Request):
async def cleanup_old_history(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Clean up old download history.

View File

@@ -1,15 +1,18 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
import json
import traceback
import logging
from routes.utils.search import search
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/search")
async def handle_search(request: Request):
async def handle_search(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Handle search requests for tracks, albums, playlists, or artists.
Frontend compatible endpoint that returns results in { items: [] } format.

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import logging
@@ -18,6 +18,9 @@ from routes.utils.watch.manager import (
CONFIG_FILE_PATH as WATCH_CONFIG_FILE_PATH,
)
# Import authentication dependencies
from routes.auth.middleware import require_admin_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -206,7 +209,7 @@ def save_watch_config_http(watch_config_data): # Renamed
@router.get("/config")
async def handle_config():
async def handle_config(current_user: User = Depends(require_admin_from_state)):
"""Handles GET requests for the main configuration."""
try:
config = get_config()
@@ -221,7 +224,7 @@ async def handle_config():
@router.post("/config")
@router.put("/config")
async def update_config(request: Request):
async def update_config(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Handles POST/PUT requests to update the main configuration."""
try:
new_config = await request.json()
@@ -271,7 +274,7 @@ async def update_config(request: Request):
@router.get("/config/check")
async def check_config_changes():
async def check_config_changes(current_user: User = Depends(require_admin_from_state)):
# This endpoint seems more related to dynamically checking if config changed
# on disk, which might not be necessary if settings are applied on restart
# or by a dedicated manager. For now, just return current config.
@@ -287,7 +290,7 @@ async def check_config_changes():
@router.post("/config/validate")
async def validate_config_endpoint(request: Request):
async def validate_config_endpoint(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Validate configuration without saving it."""
try:
config_data = await request.json()
@@ -313,7 +316,7 @@ async def validate_config_endpoint(request: Request):
@router.post("/config/watch/validate")
async def validate_watch_config_endpoint(request: Request):
async def validate_watch_config_endpoint(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Validate watch configuration without saving it."""
try:
watch_data = await request.json()
@@ -339,7 +342,7 @@ async def validate_watch_config_endpoint(request: Request):
@router.get("/config/watch")
async def handle_watch_config():
async def handle_watch_config(current_user: User = Depends(require_admin_from_state)):
"""Handles GET requests for the watch configuration."""
try:
watch_config = get_watch_config_http()
@@ -354,7 +357,7 @@ async def handle_watch_config():
@router.post("/config/watch")
@router.put("/config/watch")
async def update_watch_config(request: Request):
async def update_watch_config(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Handles POST/PUT requests to update the watch configuration."""
try:
new_watch_config = await request.json()

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse, StreamingResponse
import logging
import time
@@ -15,6 +15,9 @@ from routes.utils.celery_tasks import (
ProgressState,
)
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, get_current_user_from_state, User
# Configure logging
logger = logging.getLogger(__name__)
@@ -575,7 +578,7 @@ async def get_paginated_tasks(page=1, limit=20, active_only=False, request: Requ
# Otherwise "updates" gets matched as a {task_id} parameter!
@router.get("/list")
async def list_tasks(request: Request):
async def list_tasks(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve a paginated list of all tasks in the system.
Returns a detailed list of task objects including status and metadata.
@@ -704,7 +707,7 @@ async def list_tasks(request: Request):
@router.get("/updates")
async def get_task_updates(request: Request):
async def get_task_updates(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve only tasks that have been updated since the specified timestamp.
This endpoint is optimized for polling to reduce unnecessary data transfer.
@@ -791,7 +794,7 @@ async def get_task_updates(request: Request):
# Sort by priority (active first, then by creation time)
all_returned_tasks.sort(key=lambda x: (
0 if x.get("task_id") in [t["task_id"] for t in active_tasks] else 1,
-x.get("created_at", 0)
-(x.get("created_at") or 0)
))
response = {
@@ -823,7 +826,7 @@ async def get_task_updates(request: Request):
@router.post("/cancel/all")
async def cancel_all_tasks():
async def cancel_all_tasks(current_user: User = Depends(require_auth_from_state)):
"""
Cancel all active (running or queued) tasks.
"""
@@ -856,7 +859,7 @@ async def cancel_all_tasks():
@router.post("/cancel/{task_id}")
async def cancel_task_endpoint(task_id: str):
async def cancel_task_endpoint(task_id: str, current_user: User = Depends(require_auth_from_state)):
"""
Cancel a running or queued task.
@@ -888,7 +891,7 @@ async def cancel_task_endpoint(task_id: str):
@router.delete("/delete/{task_id}")
async def delete_task(task_id: str):
async def delete_task(task_id: str, current_user: User = Depends(require_auth_from_state)):
"""
Delete a task's information and history.
@@ -907,10 +910,11 @@ async def delete_task(task_id: str):
@router.get("/stream")
async def stream_task_updates(request: Request):
async def stream_task_updates(request: Request, current_user: User = Depends(get_current_user_from_state)):
"""
Stream real-time task updates via Server-Sent Events (SSE).
Now uses event-driven architecture for true real-time updates.
Uses optional authentication to avoid breaking SSE connections.
Query parameters:
active_only (bool): If true, only stream active tasks (downloading, processing, etc.)
@@ -1101,7 +1105,7 @@ async def generate_task_update_event(since_timestamp: float, active_only: bool,
# Sort by priority (active first, then by creation time)
all_returned_tasks.sort(key=lambda x: (
0 if x.get("task_id") in [t["task_id"] for t in active_tasks] else 1,
-x.get("created_at", 0)
-(x.get("created_at") or 0)
))
initial_data = {
@@ -1127,7 +1131,7 @@ async def generate_task_update_event(since_timestamp: float, active_only: bool,
# IMPORTANT: This parameterized route MUST come AFTER all specific routes
# Otherwise FastAPI will match specific routes like "/updates" as task_id parameters
@router.get("/{task_id}")
async def get_task_details(task_id: str, request: Request):
async def get_task_details(task_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Return a JSON object with the resource type, its name (title),
the last progress update, and, if available, the original request parameters.

View File

@@ -1,6 +1,7 @@
import { useContext, useState, useRef, useEffect } from "react";
import { FaTimes, FaSync, FaCheckCircle, FaExclamationCircle, FaHourglassHalf, FaMusic, FaCompactDisc, FaStepForward } from "react-icons/fa";
import { QueueContext, type QueueItem, getStatus, getProgress, getCurrentTrackInfo, isActiveStatus, isTerminalStatus } from "@/contexts/queue-context";
import { authApiClient } from "@/lib/api-client";
// Circular Progress Component
const CircularProgress = ({
@@ -451,6 +452,10 @@ const QueueItemCard = ({ item, cachedStatus }: { item: QueueItem, cachedStatus:
export const Queue = () => {
const context = useContext(QueueContext);
// Check if user is authenticated
const hasValidToken = authApiClient.getToken() !== null;
const [startY, setStartY] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragDistance, setDragDistance] = useState(0);
@@ -746,7 +751,7 @@ export const Queue = () => {
};
}, [isVisible]);
if (!context || !isVisible) return null;
if (!context || !isVisible || !hasValidToken) return null;
// Optimize: Calculate status once per item and reuse throughout render
const itemsWithStatus = items.map(item => ({

View File

@@ -0,0 +1,311 @@
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
import { toast } from "sonner";
import type { LoginRequest, RegisterRequest, AuthError } from "@/types/auth";
interface LoginScreenProps {
onSuccess?: () => void;
}
export function LoginScreen({ onSuccess }: LoginScreenProps) {
const { login, register, isLoading, authEnabled, isRemembered } = useAuth();
const [isLoginMode, setIsLoginMode] = useState(true);
const [formData, setFormData] = useState({
username: "",
password: "",
email: "",
confirmPassword: "",
rememberMe: true, // Default to true for better UX
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize remember me checkbox with stored preference
useEffect(() => {
setFormData(prev => ({
...prev,
rememberMe: isRemembered(),
}));
}, [isRemembered]);
// If auth is not enabled, don't show the login screen
if (!authEnabled) {
return null;
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Username validation
if (!formData.username.trim()) {
newErrors.username = "Username is required";
} else if (formData.username.length < 3) {
newErrors.username = "Username must be at least 3 characters";
}
// Password validation
if (!formData.password) {
newErrors.password = "Password is required";
} else if (formData.password.length < 6) {
newErrors.password = "Password must be at least 6 characters";
}
// Registration-specific validation
if (!isLoginMode) {
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "Please enter a valid email address";
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "Passwords do not match";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
if (isLoginMode) {
const loginData: LoginRequest = {
username: formData.username.trim(),
password: formData.password,
};
await login(loginData, formData.rememberMe);
onSuccess?.();
} else {
const registerData: RegisterRequest = {
username: formData.username.trim(),
password: formData.password,
email: formData.email.trim() || undefined,
};
await register(registerData);
// After successful registration, switch to login mode
setIsLoginMode(true);
setFormData({ ...formData, password: "", confirmPassword: "" });
toast.success("Registration successful! Please log in.");
}
} catch (error) {
const authError = error as AuthError;
toast.error(isLoginMode ? "Login Failed" : "Registration Failed", {
description: authError.message,
});
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (typeof value === 'string' && errors[field]) {
setErrors(prev => ({ ...prev, [field]: "" }));
}
};
const toggleMode = () => {
setIsLoginMode(!isLoginMode);
setErrors({});
setFormData({
username: "",
password: "",
email: "",
confirmPassword: "",
rememberMe: formData.rememberMe, // Preserve remember me preference
});
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-surface to-surface-secondary dark:from-surface-dark dark:to-surface-secondary-dark p-4">
<div className="w-full max-w-md">
{/* Logo/Brand */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
</div>
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">
Spotizerr
</h1>
<p className="text-content-secondary dark:text-content-secondary-dark mt-2">
{isLoginMode ? "Welcome back" : "Create your account"}
</p>
</div>
{/* Form */}
<div className="bg-surface dark:bg-surface-dark rounded-2xl shadow-xl border border-border dark:border-border-dark p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Username
</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) => handleInputChange("username", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.username
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter your username"
disabled={isSubmitting || isLoading}
/>
{errors.username && (
<p className="mt-1 text-sm text-error">{errors.username}</p>
)}
</div>
{/* Email (Registration only) */}
{!isLoginMode && (
<div>
<label htmlFor="email" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Email (optional)
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.email
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter your email"
disabled={isSubmitting || isLoading}
/>
{errors.email && (
<p className="mt-1 text-sm text-error">{errors.email}</p>
)}
</div>
)}
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Password
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.password
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter your password"
disabled={isSubmitting || isLoading}
/>
{errors.password && (
<p className="mt-1 text-sm text-error">{errors.password}</p>
)}
</div>
{/* Confirm Password (Registration only) */}
{!isLoginMode && (
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.confirmPassword
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Confirm your password"
disabled={isSubmitting || isLoading}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-error">{errors.confirmPassword}</p>
)}
</div>
)}
{/* Remember Me Checkbox (Login only) */}
{isLoginMode && (
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
type="checkbox"
id="rememberMe"
checked={formData.rememberMe}
onChange={(e) => handleInputChange("rememberMe", e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary/20 border-input-border dark:border-input-border-dark rounded"
disabled={isSubmitting || isLoading}
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-content-primary dark:text-content-primary-dark">
Remember me
</label>
</div>
<div className="text-xs text-content-muted dark:text-content-muted-dark">
{formData.rememberMe ? "Stay signed in" : "Session only"}
</div>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting || isLoading}
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isSubmitting || isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
{isLoginMode ? "Signing in..." : "Creating account..."}
</>
) : (
<>{isLoginMode ? "Sign In" : "Create Account"}</>
)}
</button>
</form>
{/* Toggle Mode */}
<div className="mt-6 text-center">
<p className="text-content-secondary dark:text-content-secondary-dark">
{isLoginMode ? "Don't have an account? " : "Already have an account? "}
<button
type="button"
onClick={toggleMode}
disabled={isSubmitting || isLoading}
className="text-primary hover:text-primary-hover font-medium transition-colors disabled:opacity-50"
>
{isLoginMode ? "Create one" : "Sign in"}
</button>
</p>
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center">
<p className="text-sm text-content-muted dark:text-content-muted-dark">
Secure music download platform
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { useAuth } from "@/contexts/auth-context";
import { LoginScreen } from "./LoginScreen";
interface ProtectedRouteProps {
children: ReactNode;
fallback?: ReactNode;
}
export function ProtectedRoute({ children, fallback }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, authEnabled } = useAuth();
// Show loading state while checking authentication
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface dark:bg-surface-dark">
<div className="text-center">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center mb-6 mx-auto">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<h2 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-2">
Spotizerr
</h2>
<p className="text-content-secondary dark:text-content-secondary-dark">
{authEnabled ? "Restoring your session..." : "Loading application..."}
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-2">
{authEnabled ? "Checking stored credentials" : "Authentication disabled"}
</p>
</div>
</div>
);
}
// If authentication is disabled, always show children
if (!authEnabled) {
return <>{children}</>;
}
// If authenticated, show children
if (isAuthenticated) {
return <>{children}</>;
}
// If not authenticated, show fallback or login screen
return fallback || <LoginScreen />;
}

View File

@@ -0,0 +1,102 @@
import { useState, useRef, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
export function UserMenu() {
const { user, logout, authEnabled, isRemembered } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Don't render if auth is disabled or user is not logged in
if (!authEnabled || !user) {
return null;
}
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
const handleLogout = async () => {
try {
await logout();
setIsOpen(false);
} catch (error) {
console.error("Logout failed:", error);
}
};
const sessionType = isRemembered();
return (
<div className="relative" ref={menuRef}>
{/* User Avatar/Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark transition-colors"
title={`Logged in as ${user.username}${sessionType ? " (persistent session)" : " (session only)"}`}
>
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white font-medium text-sm relative">
{user.username.charAt(0).toUpperCase()}
{/* Session type indicator */}
<div className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border border-surface dark:border-surface-dark ${
sessionType ? "bg-green-500" : "bg-orange-500"
}`} title={sessionType ? "Persistent session" : "Session only"} />
</div>
<span className="hidden sm:inline text-sm font-medium text-content-secondary dark:text-content-secondary-dark max-w-20 truncate">
{user.username}
</span>
<svg
className={`w-4 h-4 text-content-muted dark:text-content-muted-dark transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-lg shadow-xl z-50">
<div className="p-3 border-b border-border dark:border-border-dark">
<p className="font-medium text-content-primary dark:text-content-primary-dark">
{user.username}
</p>
{user.email && (
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{user.email}
</p>
)}
<p className="text-xs text-content-muted dark:text-content-muted-dark">
{user.role === "admin" ? "Administrator" : "User"}
</p>
<div className={`text-xs mt-1 flex items-center gap-1 ${
sessionType ? "text-green-600 dark:text-green-400" : "text-orange-600 dark:text-orange-400"
}`}>
<div className={`w-2 h-2 rounded-full ${sessionType ? "bg-green-500" : "bg-orange-500"}`} />
{sessionType ? "Persistent session" : "Session only"}
</div>
</div>
<div className="p-2">
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark transition-colors"
>
Sign Out
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -21,7 +21,7 @@ interface AccountFormData {
// --- API Functions ---
const fetchCredentials = async (service: Service): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
@@ -31,12 +31,12 @@ const addCredential = async ({ service, data }: { service: Service; data: Accoun
? { blob_content: data.authBlob, region: data.accountRegion }
: { arl: data.arl, region: data.accountRegion };
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload);
const { data: response } = await authApiClient.client.post(`/credentials/${service}/${data.accountName}`, payload);
return response;
};
const deleteCredential = async ({ service, name }: { service: Service; name: string }) => {
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`);
const { data: response } = await authApiClient.client.delete(`/credentials/${service}/${name}`);
return response;
};

View File

@@ -1,5 +1,5 @@
import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
@@ -50,17 +50,17 @@ const CONVERSION_FORMATS: Record<string, string[]> = {
// --- API Functions ---
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
const { data: response } = await apiClient.post("/config", data);
const { data: response } = await authApiClient.client.post("/config", data);
return response;
};
const fetchWatchConfig = async (): Promise<WatchConfig> => {
const { data } = await apiClient.get("/config/watch");
const { data } = await authApiClient.client.get("/config/watch");
return data;
};
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};

View File

@@ -1,6 +1,6 @@
import { useRef } from "react";
import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -23,7 +23,7 @@ interface FormattingTabProps {
// --- API Functions ---
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
const { data: response } = await apiClient.post("/config", data);
const { data: response } = await authApiClient.client.post("/config", data);
return response;
};

View File

@@ -1,5 +1,5 @@
import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "../../contexts/settings-context";
@@ -23,12 +23,12 @@ interface GeneralTabProps {
// --- API Functions ---
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
const saveGeneralConfig = async (data: Partial<GeneralSettings>) => {
const { data: response } = await apiClient.post("/config", data);
const { data: response } = await authApiClient.client.post("/config", data);
return response;
};

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -18,10 +18,10 @@ interface WebhookSettings {
// --- API Functions ---
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
const { data } = await apiClient.get("/credentials/spotify_api_config");
const { data } = await authApiClient.client.get("/credentials/spotify_api_config");
return data;
};
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put("/credentials/spotify_api_config", data);
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => authApiClient.client.put("/credentials/spotify_api_config", data);
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
// Mock a response since backend endpoint doesn't exist

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useForm, type SubmitHandler, Controller } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -34,22 +34,22 @@ interface Credential {
// --- API Functions ---
const fetchWatchConfig = async (): Promise<WatchSettings> => {
const { data } = await apiClient.get("/config/watch");
const { data } = await authApiClient.client.get("/config/watch");
return data;
};
const fetchDownloadConfig = async (): Promise<DownloadSettings> => {
const { data } = await apiClient.get("/config");
const { data } = await authApiClient.client.get("/config");
return data;
};
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
const { data: response } = await apiClient.post("/config/watch", data);
const { data: response } = await authApiClient.client.post("/config/watch", data);
return response;
};

View File

@@ -0,0 +1,245 @@
import { useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { AuthContext } from "./auth-context";
import { authApiClient } from "@/lib/api-client";
import type {
User,
LoginRequest,
RegisterRequest,
AuthError
} from "@/types/auth";
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authEnabled, setAuthEnabled] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
// Guard to prevent multiple simultaneous initializations
const initializingRef = useRef(false);
const isAuthenticated = user !== null;
// Initialize authentication on app start
const initializeAuth = useCallback(async () => {
// Prevent multiple simultaneous initializations
if (initializingRef.current) {
console.log("Authentication initialization already in progress, skipping...");
return;
}
try {
initializingRef.current = true;
setIsLoading(true);
console.log("Initializing authentication...");
// Check if we have a stored token first, before making any API calls
const hasStoredToken = authApiClient.getToken() !== null;
console.log("Has stored token:", hasStoredToken);
if (hasStoredToken) {
// If we have a stored token, validate it first
console.log("Validating stored token...");
const tokenValidation = await authApiClient.validateStoredToken();
if (tokenValidation.isValid && tokenValidation.userData) {
// Token is valid and we have user data
setAuthEnabled(tokenValidation.userData.auth_enabled);
if (tokenValidation.userData.authenticated && tokenValidation.userData.user) {
setUser(tokenValidation.userData.user);
console.log("Session restored for user:", tokenValidation.userData.user.username);
setIsInitialized(true);
return;
} else {
setUser(null);
console.log("Token valid but no user data");
}
} else {
setUser(null);
console.log("Stored token is invalid, cleared");
}
}
// If no stored token or token validation failed, check auth status without token
console.log("Checking auth status...");
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
if (!status.auth_enabled) {
console.log("Authentication is disabled");
setUser(null);
setIsInitialized(true);
return;
}
// If auth is enabled but we're not authenticated, user needs to log in
setUser(null);
console.log("Authentication required");
} catch (error: any) {
console.error("Auth initialization failed:", error);
setUser(null);
// Only clear all auth data on critical initialization failures
// Don't clear tokens due to network errors
if (error.message?.includes("Network Error") || error.code === "ECONNABORTED") {
console.log("Network error during auth initialization, keeping stored token");
} else {
authApiClient.clearAllAuthData();
}
} finally {
initializingRef.current = false;
setIsLoading(false);
setIsInitialized(true);
console.log("Authentication initialization complete");
}
}, []);
// Initialize on mount
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// Check authentication status (for manual refresh)
const checkAuthStatus = useCallback(async () => {
if (!isInitialized) {
return; // Don't check until initialized
}
try {
setIsLoading(true);
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
if (status.auth_enabled && status.authenticated && status.user) {
setUser(status.user);
} else {
setUser(null);
// Clear any stale token
if (authApiClient.getToken()) {
authApiClient.clearToken();
}
}
} catch (error) {
console.error("Auth status check failed:", error);
setUser(null);
authApiClient.clearToken();
} finally {
setIsLoading(false);
}
}, [isInitialized]);
// Login function with remember me option
const login = async (credentials: LoginRequest, rememberMe: boolean = true): Promise<void> => {
try {
setIsLoading(true);
const response = await authApiClient.login(credentials, rememberMe);
setUser(response.user);
console.log(`User logged in: ${response.user.username} (remember: ${rememberMe})`);
} catch (error: any) {
const authError: AuthError = {
message: error.response?.data?.detail || "Login failed",
status: error.response?.status,
};
throw authError;
} finally {
setIsLoading(false);
}
};
// Register function
const register = async (userData: RegisterRequest): Promise<void> => {
try {
setIsLoading(true);
await authApiClient.register(userData);
// Note: Registration doesn't auto-login, user needs to log in afterwards
} catch (error: any) {
const authError: AuthError = {
message: error.response?.data?.detail || "Registration failed",
status: error.response?.status,
};
throw authError;
} finally {
setIsLoading(false);
}
};
// Logout function
const logout = useCallback(async () => {
try {
await authApiClient.logout();
console.log("User logged out");
} catch (error) {
console.error("Logout error:", error);
} finally {
setUser(null);
// Don't need to call checkAuthStatus after logout since we're clearing everything
}
}, []);
// Token management
const getToken = useCallback(() => {
return authApiClient.getToken();
}, []);
const setToken = useCallback((token: string | null, rememberMe: boolean = true) => {
authApiClient.setToken(token, rememberMe);
if (token) {
// If we're setting a token, reinitialize to get user info
initializeAuth();
} else {
setUser(null);
}
}, [initializeAuth]);
// Get remember preference
const isRemembered = useCallback(() => {
return authApiClient.isRemembered();
}, []);
// Listen for storage changes (logout in another tab)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "auth_token" || e.key === "auth_remember") {
console.log("Auth storage changed in another tab");
// Re-initialize auth when storage changes
initializeAuth();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [initializeAuth]);
// Enhanced context value with new methods
const contextValue = {
// State
user,
isAuthenticated,
isLoading,
authEnabled,
// Actions
login,
register,
logout,
checkAuthStatus,
// Token management
getToken,
setToken,
// Session management
isRemembered,
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, type ReactNode, useEffect, useRef, useMemo } from "react";
import apiClient from "../lib/api-client";
import { authApiClient } from "../lib/api-client";
import {
QueueContext,
type QueueItem,
@@ -138,7 +138,16 @@ export function QueueProvider({ children }: { children: ReactNode }) {
if (sseConnection.current) return;
try {
const eventSource = new EventSource("/api/prgs/stream");
// Check if we have a valid token before connecting
const token = authApiClient.getToken();
if (!token) {
console.warn("SSE: No auth token available, skipping connection");
return;
}
// Include token as query parameter for SSE authentication
const sseUrl = `/api/prgs/stream?token=${encodeURIComponent(token)}`;
const eventSource = new EventSource(sseUrl);
sseConnection.current = eventSource;
eventSource.onopen = () => {
@@ -347,6 +356,17 @@ export function QueueProvider({ children }: { children: ReactNode }) {
eventSource.onerror = (error) => {
console.error("SSE connection error:", error);
// Check if this might be an auth error by testing if we still have a valid token
const token = authApiClient.getToken();
if (!token) {
console.warn("SSE: Connection error and no auth token - stopping reconnection attempts");
eventSource.close();
sseConnection.current = null;
stopHealthCheck();
return;
}
eventSource.close();
sseConnection.current = null;
@@ -392,7 +412,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
setIsLoadingMore(true);
try {
const nextPage = currentPage + 1;
const response = await apiClient.get(`/prgs/list?page=${nextPage}&limit=${pageSize}`);
const response = await authApiClient.client.get(`/prgs/list?page=${nextPage}&limit=${pageSize}`);
const { tasks: newTasks, pagination } = response.data;
if (newTasks.length > 0) {
@@ -421,11 +441,14 @@ export function QueueProvider({ children }: { children: ReactNode }) {
}
}, [hasMore, isLoadingMore, currentPage, createQueueItemFromTask, itemExists]);
// Note: SSE connection state is managed through the initialize effect and restartSSE method
// The auth context should call restartSSE() when login/logout occurs
// Initialize queue on mount
useEffect(() => {
const initializeQueue = async () => {
try {
const response = await apiClient.get(`/prgs/list?page=1&limit=${pageSize}`);
const response = await authApiClient.client.get(`/prgs/list?page=1&limit=${pageSize}`);
const { tasks, pagination, total_tasks, task_counts } = response.data;
const queueItems = tasks
@@ -491,7 +514,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
setItems(prev => [newItem, ...prev]);
try {
const response = await apiClient.get(`/${item.type}/download/${item.spotifyId}`);
const response = await authApiClient.client.get(`/${item.type}/download/${item.spotifyId}`);
const { task_id: taskId } = response.data;
setItems(prev =>
@@ -517,7 +540,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
const removeItem = useCallback((id: string) => {
const item = items.find(i => i.id === id);
if (item?.taskId) {
apiClient.delete(`/prgs/delete/${item.taskId}`).catch(console.error);
authApiClient.client.delete(`/prgs/delete/${item.taskId}`).catch(console.error);
}
setItems(prev => prev.filter(i => i.id !== id));
@@ -537,7 +560,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
if (!item?.taskId) return;
try {
await apiClient.post(`/prgs/cancel/${item.taskId}`);
await authApiClient.client.post(`/prgs/cancel/${item.taskId}`);
setItems(prev =>
prev.map(i =>
@@ -584,7 +607,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
}
try {
await apiClient.post("/prgs/cancel/all");
await authApiClient.client.post("/prgs/cancel/all");
activeItems.forEach(item => {
setItems(prev =>
@@ -638,6 +661,13 @@ export function QueueProvider({ children }: { children: ReactNode }) {
setIsVisible(prev => !prev);
}, []);
// Method to restart SSE (useful when auth state changes)
const restartSSE = useCallback(() => {
console.log("SSE: Restarting connection due to auth state change");
disconnectSSE();
setTimeout(() => connectSSE(), 1000); // Small delay to ensure clean disconnect
}, [connectSSE, disconnectSSE]);
const value = {
items,
isVisible,
@@ -652,6 +682,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
clearCompleted,
cancelAll,
loadMoreTasks,
restartSSE, // Expose for auth state changes
};
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>;

View File

@@ -1,5 +1,5 @@
import { type ReactNode } from "react";
import apiClient from "../lib/api-client";
import { authApiClient } from "../lib/api-client";
import { SettingsContext, type AppSettings } from "./settings-context";
import { useQuery } from "@tanstack/react-query";
@@ -100,20 +100,30 @@ interface FetchedCamelCaseSettings {
}
const fetchSettings = async (): Promise<FlatAppSettings> => {
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
apiClient.get("/config"),
apiClient.get("/config/watch"),
]);
try {
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
authApiClient.client.get("/config"),
authApiClient.client.get("/config/watch"),
]);
const combinedConfig = {
...generalConfig,
watch: watchConfig,
};
const combinedConfig = {
...generalConfig,
watch: watchConfig,
};
// Transform the keys before returning the data
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
// Transform the keys before returning the data
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
return camelData as unknown as FlatAppSettings;
return camelData as unknown as FlatAppSettings;
} catch (error: any) {
// If we get authentication errors, return default settings
if (error.response?.status === 401 || error.response?.status === 403) {
console.log("Authentication required for config access, using default settings");
return defaultSettings;
}
// Re-throw other errors for React Query to handle
throw error;
}
};
export function SettingsProvider({ children }: { children: ReactNode }) {

View File

@@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
import type { AuthContextType } from "@/types/auth";
export const AuthContext = createContext<AuthContextType | null>(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Optional hook that doesn't throw an error if used outside provider
export function useAuthOptional() {
return useContext(AuthContext);
}

View File

@@ -157,6 +157,7 @@ export interface QueueContextType {
clearCompleted: () => void;
cancelAll: () => void;
loadMoreTasks: () => void;
restartSSE: () => void; // For auth state changes
}
export const QueueContext = createContext<QueueContextType | undefined>(undefined);

View File

@@ -1,41 +1,302 @@
import axios from "axios";
import type { AxiosInstance } from "axios";
import { toast } from "sonner";
import type {
LoginRequest,
RegisterRequest,
LoginResponse,
AuthStatusResponse,
User
} from "@/types/auth";
const apiClient = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
timeout: 10000, // 10 seconds timeout
});
class AuthApiClient {
private apiClient: AxiosInstance;
private token: string | null = null;
private isCheckingToken: boolean = false;
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => {
const contentType = response.headers["content-type"];
if (contentType && contentType.includes("application/json")) {
return response;
}
// If the response is not JSON, reject it to trigger the error handling
const error = new Error("Invalid response type. Expected JSON.");
toast.error("API Error", {
description: "Received an invalid response from the server. Expected JSON data.",
constructor() {
this.apiClient = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
timeout: 10000,
});
return Promise.reject(error);
},
(error) => {
if (error.code === "ECONNABORTED") {
toast.error("Request Timed Out", {
description: "The server did not respond in time. Please try again later.",
});
} else {
const errorMessage = error.response?.data?.error || error.message || "An unknown error occurred.";
toast.error("API Error", {
description: errorMessage,
});
}
return Promise.reject(error);
},
);
export default apiClient;
// Load token from storage on initialization
this.loadTokenFromStorage();
// Request interceptor to add auth token
this.apiClient.interceptors.request.use(
(config) => {
if (this.token) {
config.headers.Authorization = `Bearer ${this.token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
this.apiClient.interceptors.response.use(
(response) => {
const contentType = response.headers["content-type"];
if (contentType && contentType.includes("application/json")) {
return response;
}
const error = new Error("Invalid response type. Expected JSON.");
toast.error("API Error", {
description: "Received an invalid response from the server.",
});
return Promise.reject(error);
},
(error) => {
// Handle authentication errors
if (error.response?.status === 401) {
// Only clear token for auth-related endpoints
const requestUrl = error.config?.url || "";
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
if (isAuthEndpoint) {
// Clear invalid token only for auth endpoints
this.clearToken();
// Only show auth error if auth is enabled and not during initial token check
if (error.response?.data?.auth_enabled && !this.isCheckingToken) {
toast.error("Session Expired", {
description: "Please log in again to continue.",
});
}
} else {
// For non-auth endpoints, just log the 401 but don't clear token
// The token might still be valid for auth endpoints
console.log(`401 error on non-auth endpoint: ${requestUrl}`);
}
} else if (error.response?.status === 403) {
toast.error("Access Denied", {
description: "You don't have permission to perform this action.",
});
} else if (error.code === "ECONNABORTED") {
toast.error("Request Timed Out", {
description: "The server did not respond in time. Please try again later.",
});
} else {
const errorMessage = error.response?.data?.detail ||
error.response?.data?.error ||
error.message ||
"An unknown error occurred.";
// Don't show toast errors during token validation
if (!this.isCheckingToken) {
toast.error("API Error", {
description: errorMessage,
});
}
}
return Promise.reject(error);
}
);
}
// Enhanced token management with storage options
setToken(token: string | null, rememberMe: boolean = true) {
this.token = token;
if (token) {
if (rememberMe) {
// Store in localStorage for persistence across browser sessions
localStorage.setItem("auth_token", token);
localStorage.setItem("auth_remember", "true");
sessionStorage.removeItem("auth_token"); // Clear from session storage
} else {
// Store in sessionStorage for current session only
sessionStorage.setItem("auth_token", token);
localStorage.removeItem("auth_token"); // Clear from persistent storage
localStorage.removeItem("auth_remember");
}
} else {
// Clear all storage
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_remember");
sessionStorage.removeItem("auth_token");
}
}
getToken(): string | null {
return this.token;
}
isRemembered(): boolean {
return localStorage.getItem("auth_remember") === "true";
}
private loadTokenFromStorage() {
// Try localStorage first (persistent)
let token = localStorage.getItem("auth_token");
let isRemembered = localStorage.getItem("auth_remember") === "true";
// If not found in localStorage, try sessionStorage
if (!token) {
token = sessionStorage.getItem("auth_token");
isRemembered = false;
}
if (token) {
this.token = token;
console.log(`Loaded ${isRemembered ? 'persistent' : 'session'} token from storage`);
}
}
clearToken() {
// Preserve the remember me preference when clearing invalid tokens
const wasRemembered = this.isRemembered();
this.token = null;
if (wasRemembered) {
// Keep the remember preference but remove the invalid token
localStorage.removeItem("auth_token");
// Keep auth_remember flag for next login
} else {
// Session-only token, clear everything
sessionStorage.removeItem("auth_token");
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_remember");
}
}
clearAllAuthData() {
// Use this method for complete logout - clears everything
this.token = null;
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_remember");
sessionStorage.removeItem("auth_token");
}
// Enhanced token validation that returns detailed information
async validateStoredToken(): Promise<{ isValid: boolean; userData?: AuthStatusResponse }> {
if (!this.token) {
return { isValid: false };
}
try {
this.isCheckingToken = true;
const response = await this.apiClient.get<AuthStatusResponse>("/auth/status");
// If the token is valid and user is authenticated
if (response.data.auth_enabled && response.data.authenticated && response.data.user) {
console.log("Stored token is valid, user authenticated");
return { isValid: true, userData: response.data };
} else {
console.log("Stored token is invalid or user not authenticated");
this.clearToken();
return { isValid: false };
}
} catch (error) {
console.log("Token validation failed:", error);
this.clearToken();
return { isValid: false };
} finally {
this.isCheckingToken = false;
}
}
// Auth API methods
async checkAuthStatus(): Promise<AuthStatusResponse> {
const response = await this.apiClient.get<AuthStatusResponse>("/auth/status");
return response.data;
}
async login(credentials: LoginRequest, rememberMe: boolean = true): Promise<LoginResponse> {
const response = await this.apiClient.post<LoginResponse>("/auth/login", credentials);
const loginData = response.data;
// Store the token with remember preference
this.setToken(loginData.access_token, rememberMe);
toast.success("Login Successful", {
description: `Welcome back, ${loginData.user.username}!`,
});
return loginData;
}
async register(userData: RegisterRequest): Promise<{ message: string }> {
const response = await this.apiClient.post("/auth/register", userData);
toast.success("Registration Successful", {
description: "Account created successfully! You can now log in.",
});
return response.data;
}
async logout(): Promise<void> {
try {
await this.apiClient.post("/auth/logout");
} catch (error) {
// Ignore logout errors - clear token anyway
console.warn("Logout request failed:", error);
}
this.clearAllAuthData(); // Changed from this.clearToken()
toast.success("Logged Out", {
description: "You have been logged out successfully.",
});
}
async getCurrentUser(): Promise<User> {
const response = await this.apiClient.get<User>("/auth/profile");
return response.data;
}
async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const response = await this.apiClient.put("/auth/profile/password", {
current_password: currentPassword,
new_password: newPassword,
});
toast.success("Password Changed", {
description: "Your password has been updated successfully.",
});
return response.data;
}
// Admin methods
async listUsers(): Promise<User[]> {
const response = await this.apiClient.get<User[]>("/auth/users");
return response.data;
}
async deleteUser(username: string): Promise<{ message: string }> {
const response = await this.apiClient.delete(`/auth/users/${username}`);
toast.success("User Deleted", {
description: `User ${username} has been deleted.`,
});
return response.data;
}
async updateUserRole(username: string, role: "user" | "admin"): Promise<{ message: string }> {
const response = await this.apiClient.put(`/auth/users/${username}/role`, { role });
toast.success("Role Updated", {
description: `User ${username} role updated to ${role}.`,
});
return response.data;
}
// Expose the underlying axios instance for other API calls
get client() {
return this.apiClient;
}
}
// Create and export a singleton instance
export const authApiClient = new AuthApiClient();
// Export the client as default for backward compatibility
export default authApiClient.client;

View File

@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./router";
import { AuthProvider } from "./contexts/AuthProvider";
import "./index.css";
// Theme management functions
@@ -93,7 +94,9 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -81,14 +81,34 @@ export const Artist = () => {
addItem({ spotifyId: album.id, type: "album", name: album.name });
};
const handleDownloadArtist = () => {
const handleDownloadArtist = async () => {
if (!artistId || !artist) return;
toast.info(`Adding ${artist.name} to queue...`);
addItem({
spotifyId: artistId,
type: "artist",
name: artist.name,
});
try {
toast.info(`Downloading ${artist.name} discography...`);
// Call the artist download endpoint which returns album task IDs
const response = await apiClient.get(`/artist/download/${artistId}`);
if (response.data.queued_albums?.length > 0) {
toast.success(
`${artist.name} discography queued successfully!`,
{
description: `${response.data.queued_albums.length} albums added to queue.`,
}
);
} else {
toast.info("No new albums to download for this artist.");
}
} catch (error: any) {
console.error("Artist download failed:", error);
toast.error(
"Failed to download artist",
{
description: error.response?.data?.error || "An unexpected error occurred.",
}
);
}
};
const handleToggleWatch = async () => {

View File

@@ -6,13 +6,57 @@ import { AccountsTab } from "../components/config/AccountsTab";
import { WatchTab } from "../components/config/WatchTab";
import { ServerTab } from "../components/config/ServerTab";
import { useSettings } from "../contexts/settings-context";
import { useAuth } from "../contexts/auth-context";
import { LoginScreen } from "../components/auth/LoginScreen";
const ConfigComponent = () => {
const [activeTab, setActiveTab] = useState("general");
const { user, isAuthenticated, authEnabled, isLoading: authLoading } = useAuth();
// Get settings from the context instead of fetching here
const { settings: config, isLoading } = useSettings();
// Show loading while authentication is being checked
if (authLoading) {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8">
<div className="text-center py-12">
<p className="text-content-muted dark:text-content-muted-dark">Loading...</p>
</div>
</div>
);
}
// Show login screen if authentication is enabled but user is not authenticated
if (authEnabled && !isAuthenticated) {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8">
<div className="space-y-4 text-center mb-6">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Configuration</h1>
<p className="text-content-muted dark:text-content-muted-dark">Please log in to access configuration settings.</p>
</div>
<LoginScreen />
</div>
);
}
// Check for admin role if authentication is enabled
if (authEnabled && isAuthenticated && user?.role !== "admin") {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8">
<div className="text-center py-12">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark mb-4">Access Denied</h1>
<p className="text-content-muted dark:text-content-muted-dark">
You need administrator privileges to access configuration settings.
</p>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-2">
Current role: <span className="font-medium">{user?.role || 'user'}</span>
</p>
</div>
</div>
);
}
const renderTabContent = () => {
if (isLoading) return <div className="text-center py-12"><p className="text-content-muted dark:text-content-muted-dark">Loading configuration...</p></div>;
if (!config) return <div className="text-center py-12"><p className="text-error-text bg-error-muted p-4 rounded-lg">Error loading configuration.</p></div>;
@@ -40,6 +84,11 @@ const ConfigComponent = () => {
<div className="space-y-2">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Configuration</h1>
<p className="text-content-muted dark:text-content-muted-dark">Manage application settings and services.</p>
{authEnabled && user && (
<p className="text-sm text-content-muted dark:text-content-muted-dark">
Logged in as: <span className="font-medium">{user.username}</span> ({user.role})
</p>
)}
</div>
<div className="flex flex-col lg:flex-row gap-6 lg:gap-10">

View File

@@ -3,6 +3,8 @@ import { QueueProvider } from "@/contexts/QueueProvider";
import { SettingsProvider } from "@/contexts/SettingsProvider";
import { QueueContext } from "@/contexts/queue-context";
import { Queue } from "@/components/Queue";
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
import { UserMenu } from "@/components/auth/UserMenu";
import { useContext, useState, useEffect } from "react";
import { getTheme, toggleTheme } from "@/main";
@@ -77,7 +79,7 @@ function ThemeToggle() {
}
function AppLayout() {
const { toggleVisibility, activeCount, totalTasks } = useContext(QueueContext) || {};
const { toggleVisibility, totalTasks } = useContext(QueueContext) || {};
return (
<div className="min-h-screen bg-gradient-to-br from-surface-secondary via-surface-muted to-surface-accent dark:from-surface-dark dark:via-surface-muted-dark dark:to-surface-secondary-dark text-content-primary dark:text-content-primary-dark flex flex-col">
@@ -89,6 +91,7 @@ function AppLayout() {
</Link>
<div className="flex items-center gap-2">
<ThemeToggle />
<UserMenu />
<Link to="/watchlist" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6 logo" />
</Link>
@@ -124,6 +127,7 @@ function AppLayout() {
<img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
</Link>
<ThemeToggle />
<UserMenu />
</div>
</header>
@@ -170,7 +174,9 @@ export default function Root() {
return (
<SettingsProvider>
<QueueProvider>
<AppLayout />
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
</QueueProvider>
</SettingsProvider>
);

View File

@@ -0,0 +1,57 @@
// User and authentication types
export interface User {
username: string;
email?: string;
role: "user" | "admin";
created_at: string;
last_login?: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: User;
}
export interface AuthStatusResponse {
auth_enabled: boolean;
authenticated: boolean;
user?: User;
}
export interface AuthContextType {
// State
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
authEnabled: boolean;
// Actions
login: (credentials: LoginRequest, rememberMe?: boolean) => Promise<void>;
register: (userData: RegisterRequest) => Promise<void>;
logout: () => void;
checkAuthStatus: () => Promise<void>;
// Token management
getToken: () => string | null;
setToken: (token: string | null, rememberMe?: boolean) => void;
// Session management
isRemembered: () => boolean;
}
export interface AuthError {
message: string;
status?: number;
}