First steps for auth
This commit is contained in:
11
.env.example
11
.env.example
@@ -17,3 +17,14 @@ PGID=1000
|
||||
|
||||
# Optional: Sets the default file permissions for newly created files within the container.
|
||||
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
280
AUTH_SETUP.md
Normal 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
13
app.py
@@ -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"])
|
||||
|
||||
@@ -3,3 +3,6 @@ uvicorn[standard]==0.32.1
|
||||
celery==5.5.3
|
||||
deezspot-spotizerr==2.2.2
|
||||
httpx==0.28.1
|
||||
bcrypt==4.2.1
|
||||
PyJWT==2.10.1
|
||||
python-multipart==0.0.17
|
||||
@@ -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
254
routes/auth/auth.py
Normal 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")
|
||||
@@ -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
177
routes/auth/middleware.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
311
spotizerr-ui/src/components/auth/LoginScreen.tsx
Normal file
311
spotizerr-ui/src/components/auth/LoginScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
spotizerr-ui/src/components/auth/ProtectedRoute.tsx
Normal file
50
spotizerr-ui/src/components/auth/ProtectedRoute.tsx
Normal 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 />;
|
||||
}
|
||||
102
spotizerr-ui/src/components/auth/UserMenu.tsx
Normal file
102
spotizerr-ui/src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
245
spotizerr-ui/src/contexts/AuthProvider.tsx
Normal file
245
spotizerr-ui/src/contexts/AuthProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
17
spotizerr-ui/src/contexts/auth-context.ts
Normal file
17
spotizerr-ui/src/contexts/auth-context.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
57
spotizerr-ui/src/types/auth.ts
Normal file
57
spotizerr-ui/src/types/auth.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user