From 6ab603d90ab61270d97b303716d50ea388e4e245 Mon Sep 17 00:00:00 2001 From: Xoconoch Date: Sun, 3 Aug 2025 20:16:07 -0600 Subject: [PATCH] First steps for auth --- .env.example | 13 +- AUTH_SETUP.md | 280 +++++++++++++++ app.py | 13 + requirements.txt | 5 +- routes/auth/__init__.py | 221 ++++++++++++ routes/auth/auth.py | 254 ++++++++++++++ routes/auth/credentials.py | 21 +- routes/auth/middleware.py | 177 ++++++++++ routes/content/album.py | 11 +- routes/content/artist.py | 25 +- routes/content/playlist.py | 31 +- routes/content/track.py | 11 +- routes/core/history.py | 21 +- routes/core/search.py | 7 +- routes/system/config.py | 19 +- routes/system/progress.py | 24 +- spotizerr-ui/src/components/Queue.tsx | 7 +- .../src/components/auth/LoginScreen.tsx | 311 ++++++++++++++++ .../src/components/auth/ProtectedRoute.tsx | 50 +++ spotizerr-ui/src/components/auth/UserMenu.tsx | 102 ++++++ .../src/components/config/AccountsTab.tsx | 8 +- .../src/components/config/DownloadsTab.tsx | 8 +- .../src/components/config/FormattingTab.tsx | 4 +- .../src/components/config/GeneralTab.tsx | 6 +- .../src/components/config/ServerTab.tsx | 6 +- .../src/components/config/WatchTab.tsx | 10 +- spotizerr-ui/src/contexts/AuthProvider.tsx | 245 +++++++++++++ spotizerr-ui/src/contexts/QueueProvider.tsx | 47 ++- .../src/contexts/SettingsProvider.tsx | 34 +- spotizerr-ui/src/contexts/auth-context.ts | 17 + spotizerr-ui/src/contexts/queue-context.ts | 1 + spotizerr-ui/src/lib/api-client.ts | 331 ++++++++++++++++-- spotizerr-ui/src/main.tsx | 5 +- spotizerr-ui/src/routes/artist.tsx | 34 +- spotizerr-ui/src/routes/config.tsx | 49 +++ spotizerr-ui/src/routes/root.tsx | 10 +- spotizerr-ui/src/types/auth.ts | 57 +++ 37 files changed, 2315 insertions(+), 160 deletions(-) create mode 100644 AUTH_SETUP.md create mode 100644 routes/auth/auth.py create mode 100644 routes/auth/middleware.py create mode 100644 spotizerr-ui/src/components/auth/LoginScreen.tsx create mode 100644 spotizerr-ui/src/components/auth/ProtectedRoute.tsx create mode 100644 spotizerr-ui/src/components/auth/UserMenu.tsx create mode 100644 spotizerr-ui/src/contexts/AuthProvider.tsx create mode 100644 spotizerr-ui/src/contexts/auth-context.ts create mode 100644 spotizerr-ui/src/types/auth.ts diff --git a/.env.example b/.env.example index 303d5a9..bbd0116 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,15 @@ PUID=1000 PGID=1000 # Optional: Sets the default file permissions for newly created files within the container. -UMASK=0022 \ No newline at end of file +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 \ No newline at end of file diff --git a/AUTH_SETUP.md b/AUTH_SETUP.md new file mode 100644 index 0000000..21aecf5 --- /dev/null +++ b/AUTH_SETUP.md @@ -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
Please log in
; + } + + const sessionType = isRemembered() ? "persistent" : "session-only"; + + return ( +
+ Hello, {user.username}! ({sessionType} session) +
+ ); +} +``` + +### 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 \ No newline at end of file diff --git a/app.py b/app.py index bd0730f..7a14e5e 100755 --- a/app.py +++ b/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"]) diff --git a/requirements.txt b/requirements.txt index a03581f..a13d422 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ fastapi==0.115.6 uvicorn[standard]==0.32.1 celery==5.5.3 deezspot-spotizerr==2.2.2 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +bcrypt==4.2.1 +PyJWT==2.10.1 +python-multipart==0.0.17 \ No newline at end of file diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py index e69de29..6a3aaf6 100644 --- a/routes/auth/__init__.py +++ b/routes/auth/__init__.py @@ -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() diff --git a/routes/auth/auth.py b/routes/auth/auth.py new file mode 100644 index 0000000..6c33bfa --- /dev/null +++ b/routes/auth/auth.py @@ -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") \ No newline at end of file diff --git a/routes/auth/credentials.py b/routes/auth/credentials.py index f58037d..e0e7c28 100755 --- a/routes/auth/credentials.py +++ b/routes/auth/credentials.py @@ -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. """ diff --git a/routes/auth/middleware.py b/routes/auth/middleware.py new file mode 100644 index 0000000..0ed36ca --- /dev/null +++ b/routes/auth/middleware.py @@ -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 \ No newline at end of file diff --git a/routes/content/album.py b/routes/content/album.py index e839da2..52abdf5 100755 --- a/routes/content/album.py +++ b/routes/content/album.py @@ -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. diff --git a/routes/content/artist.py b/routes/content/artist.py index 0332c50..33c14db 100644 --- a/routes/content/artist.py +++ b/routes/content/artist.py @@ -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): diff --git a/routes/content/playlist.py b/routes/content/playlist.py index 9582c3e..fd359b6 100755 --- a/routes/content/playlist.py +++ b/routes/content/playlist.py @@ -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): diff --git a/routes/content/track.py b/routes/content/track.py index 9dd14ec..835e4f8 100755 --- a/routes/content/track.py +++ b/routes/content/track.py @@ -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. diff --git a/routes/core/history.py b/routes/core/history.py index 024831d..ca6ec6e 100644 --- a/routes/core/history.py +++ b/routes/core/history.py @@ -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. diff --git a/routes/core/search.py b/routes/core/search.py index 2410e06..b30b8ef 100755 --- a/routes/core/search.py +++ b/routes/core/search.py @@ -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. diff --git a/routes/system/config.py b/routes/system/config.py index 9318a96..0f4fcfa 100644 --- a/routes/system/config.py +++ b/routes/system/config.py @@ -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() diff --git a/routes/system/progress.py b/routes/system/progress.py index bb9c601..d23ab23 100755 --- a/routes/system/progress.py +++ b/routes/system/progress.py @@ -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. diff --git a/spotizerr-ui/src/components/Queue.tsx b/spotizerr-ui/src/components/Queue.tsx index c190b4f..e79fa03 100644 --- a/spotizerr-ui/src/components/Queue.tsx +++ b/spotizerr-ui/src/components/Queue.tsx @@ -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(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 => ({ diff --git a/spotizerr-ui/src/components/auth/LoginScreen.tsx b/spotizerr-ui/src/components/auth/LoginScreen.tsx new file mode 100644 index 0000000..ba90e89 --- /dev/null +++ b/spotizerr-ui/src/components/auth/LoginScreen.tsx @@ -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>({}); + 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 = {}; + + // 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 ( +
+
+ {/* Logo/Brand */} +
+
+
+ + + +
+
+

+ Spotizerr +

+

+ {isLoginMode ? "Welcome back" : "Create your account"} +

+
+ + {/* Form */} +
+
+ {/* Username */} +
+ + 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 && ( +

{errors.username}

+ )} +
+ + {/* Email (Registration only) */} + {!isLoginMode && ( +
+ + 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 && ( +

{errors.email}

+ )} +
+ )} + + {/* Password */} +
+ + 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 && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password (Registration only) */} + {!isLoginMode && ( +
+ + 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 && ( +

{errors.confirmPassword}

+ )} +
+ )} + + {/* Remember Me Checkbox (Login only) */} + {isLoginMode && ( +
+
+ 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} + /> + +
+
+ {formData.rememberMe ? "Stay signed in" : "Session only"} +
+
+ )} + + {/* Submit Button */} + +
+ + {/* Toggle Mode */} +
+

+ {isLoginMode ? "Don't have an account? " : "Already have an account? "} + +

+
+
+ + {/* Footer */} +
+

+ Secure music download platform +

+
+
+
+ ); +} \ No newline at end of file diff --git a/spotizerr-ui/src/components/auth/ProtectedRoute.tsx b/spotizerr-ui/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..1c0f4be --- /dev/null +++ b/spotizerr-ui/src/components/auth/ProtectedRoute.tsx @@ -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 ( +
+
+
+ + + +
+
+

+ Spotizerr +

+

+ {authEnabled ? "Restoring your session..." : "Loading application..."} +

+

+ {authEnabled ? "Checking stored credentials" : "Authentication disabled"} +

+
+
+ ); + } + + // 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 || ; +} \ No newline at end of file diff --git a/spotizerr-ui/src/components/auth/UserMenu.tsx b/spotizerr-ui/src/components/auth/UserMenu.tsx new file mode 100644 index 0000000..c092cb3 --- /dev/null +++ b/spotizerr-ui/src/components/auth/UserMenu.tsx @@ -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(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 ( +
+ {/* User Avatar/Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+
+

+ {user.username} +

+ {user.email && ( +

+ {user.email} +

+ )} +

+ {user.role === "admin" ? "Administrator" : "User"} +

+
+
+ {sessionType ? "Persistent session" : "Session only"} +
+
+ +
+ +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/spotizerr-ui/src/components/config/AccountsTab.tsx b/spotizerr-ui/src/components/config/AccountsTab.tsx index 0823955..8db7ba3 100644 --- a/spotizerr-ui/src/components/config/AccountsTab.tsx +++ b/spotizerr-ui/src/components/config/AccountsTab.tsx @@ -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 => { - const { data } = await apiClient.get(`/credentials/${service}`); + const { data } = await authApiClient.client.get(`/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; }; diff --git a/spotizerr-ui/src/components/config/DownloadsTab.tsx b/spotizerr-ui/src/components/config/DownloadsTab.tsx index dcf522f..732cf5c 100644 --- a/spotizerr-ui/src/components/config/DownloadsTab.tsx +++ b/spotizerr-ui/src/components/config/DownloadsTab.tsx @@ -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 = { // --- API Functions --- const saveDownloadConfig = async (data: Partial) => { - const { data: response } = await apiClient.post("/config", data); + const { data: response } = await authApiClient.client.post("/config", data); return response; }; const fetchWatchConfig = async (): Promise => { - const { data } = await apiClient.get("/config/watch"); + const { data } = await authApiClient.client.get("/config/watch"); return data; }; const fetchCredentials = async (service: "spotify" | "deezer"): Promise => { - const { data } = await apiClient.get(`/credentials/${service}`); + const { data } = await authApiClient.client.get(`/credentials/${service}`); return data.map((name) => ({ name })); }; diff --git a/spotizerr-ui/src/components/config/FormattingTab.tsx b/spotizerr-ui/src/components/config/FormattingTab.tsx index 2df4e34..48f51f9 100644 --- a/spotizerr-ui/src/components/config/FormattingTab.tsx +++ b/spotizerr-ui/src/components/config/FormattingTab.tsx @@ -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) => { - const { data: response } = await apiClient.post("/config", data); + const { data: response } = await authApiClient.client.post("/config", data); return response; }; diff --git a/spotizerr-ui/src/components/config/GeneralTab.tsx b/spotizerr-ui/src/components/config/GeneralTab.tsx index f61c58c..a0aa968 100644 --- a/spotizerr-ui/src/components/config/GeneralTab.tsx +++ b/spotizerr-ui/src/components/config/GeneralTab.tsx @@ -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 => { - const { data } = await apiClient.get(`/credentials/${service}`); + const { data } = await authApiClient.client.get(`/credentials/${service}`); return data.map((name) => ({ name })); }; const saveGeneralConfig = async (data: Partial) => { - const { data: response } = await apiClient.post("/config", data); + const { data: response } = await authApiClient.client.post("/config", data); return response; }; diff --git a/spotizerr-ui/src/components/config/ServerTab.tsx b/spotizerr-ui/src/components/config/ServerTab.tsx index e8de35e..c71c19c 100644 --- a/spotizerr-ui/src/components/config/ServerTab.tsx +++ b/spotizerr-ui/src/components/config/ServerTab.tsx @@ -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 => { - 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 => { // Mock a response since backend endpoint doesn't exist diff --git a/spotizerr-ui/src/components/config/WatchTab.tsx b/spotizerr-ui/src/components/config/WatchTab.tsx index cae5cf1..3c819b7 100644 --- a/spotizerr-ui/src/components/config/WatchTab.tsx +++ b/spotizerr-ui/src/components/config/WatchTab.tsx @@ -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 => { - const { data } = await apiClient.get("/config/watch"); + const { data } = await authApiClient.client.get("/config/watch"); return data; }; const fetchDownloadConfig = async (): Promise => { - const { data } = await apiClient.get("/config"); + const { data } = await authApiClient.client.get("/config"); return data; }; const fetchCredentials = async (service: "spotify" | "deezer"): Promise => { - const { data } = await apiClient.get(`/credentials/${service}`); + const { data } = await authApiClient.client.get(`/credentials/${service}`); return data.map((name) => ({ name })); }; const saveWatchConfig = async (data: Partial) => { - const { data: response } = await apiClient.post("/config/watch", data); + const { data: response } = await authApiClient.client.post("/config/watch", data); return response; }; diff --git a/spotizerr-ui/src/contexts/AuthProvider.tsx b/spotizerr-ui/src/contexts/AuthProvider.tsx new file mode 100644 index 0000000..9efe16b --- /dev/null +++ b/spotizerr-ui/src/contexts/AuthProvider.tsx @@ -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(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 => { + 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 => { + 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 ( + + {children} + + ); +} \ No newline at end of file diff --git a/spotizerr-ui/src/contexts/QueueProvider.tsx b/spotizerr-ui/src/contexts/QueueProvider.tsx index 15a635c..ad1f7ae 100644 --- a/spotizerr-ui/src/contexts/QueueProvider.tsx +++ b/spotizerr-ui/src/contexts/QueueProvider.tsx @@ -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 {children}; diff --git a/spotizerr-ui/src/contexts/SettingsProvider.tsx b/spotizerr-ui/src/contexts/SettingsProvider.tsx index 35e0ab4..6103c58 100644 --- a/spotizerr-ui/src/contexts/SettingsProvider.tsx +++ b/spotizerr-ui/src/contexts/SettingsProvider.tsx @@ -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 => { - 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 }) { diff --git a/spotizerr-ui/src/contexts/auth-context.ts b/spotizerr-ui/src/contexts/auth-context.ts new file mode 100644 index 0000000..b8d4f1c --- /dev/null +++ b/spotizerr-ui/src/contexts/auth-context.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; +import type { AuthContextType } from "@/types/auth"; + +export const AuthContext = createContext(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); +} \ No newline at end of file diff --git a/spotizerr-ui/src/contexts/queue-context.ts b/spotizerr-ui/src/contexts/queue-context.ts index e870fdc..22da227 100644 --- a/spotizerr-ui/src/contexts/queue-context.ts +++ b/spotizerr-ui/src/contexts/queue-context.ts @@ -157,6 +157,7 @@ export interface QueueContextType { clearCompleted: () => void; cancelAll: () => void; loadMoreTasks: () => void; + restartSSE: () => void; // For auth state changes } export const QueueContext = createContext(undefined); diff --git a/spotizerr-ui/src/lib/api-client.ts b/spotizerr-ui/src/lib/api-client.ts index c9a3dd8..bd45843 100644 --- a/spotizerr-ui/src/lib/api-client.ts +++ b/spotizerr-ui/src/lib/api-client.ts @@ -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("/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 { + const response = await this.apiClient.get("/auth/status"); + return response.data; + } + + async login(credentials: LoginRequest, rememberMe: boolean = true): Promise { + const response = await this.apiClient.post("/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 { + 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 { + const response = await this.apiClient.get("/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 { + const response = await this.apiClient.get("/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; \ No newline at end of file diff --git a/spotizerr-ui/src/main.tsx b/spotizerr-ui/src/main.tsx index ce956c7..43feb38 100644 --- a/spotizerr-ui/src/main.tsx +++ b/spotizerr-ui/src/main.tsx @@ -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( - + + + , ); diff --git a/spotizerr-ui/src/routes/artist.tsx b/spotizerr-ui/src/routes/artist.tsx index e240c90..7664216 100644 --- a/spotizerr-ui/src/routes/artist.tsx +++ b/spotizerr-ui/src/routes/artist.tsx @@ -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 () => { diff --git a/spotizerr-ui/src/routes/config.tsx b/spotizerr-ui/src/routes/config.tsx index f2f4135..e296ed0 100644 --- a/spotizerr-ui/src/routes/config.tsx +++ b/spotizerr-ui/src/routes/config.tsx @@ -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 ( +
+
+

Loading...

+
+
+ ); + } + + // Show login screen if authentication is enabled but user is not authenticated + if (authEnabled && !isAuthenticated) { + return ( +
+
+

Configuration

+

Please log in to access configuration settings.

+
+ +
+ ); + } + + // Check for admin role if authentication is enabled + if (authEnabled && isAuthenticated && user?.role !== "admin") { + return ( +
+
+

Access Denied

+

+ You need administrator privileges to access configuration settings. +

+

+ Current role: {user?.role || 'user'} +

+
+
+ ); + } + const renderTabContent = () => { if (isLoading) return

Loading configuration...

; if (!config) return

Error loading configuration.

; @@ -40,6 +84,11 @@ const ConfigComponent = () => {

Configuration

Manage application settings and services.

+ {authEnabled && user && ( +

+ Logged in as: {user.username} ({user.role}) +

+ )}
diff --git a/spotizerr-ui/src/routes/root.tsx b/spotizerr-ui/src/routes/root.tsx index 5d54f12..c168a49 100644 --- a/spotizerr-ui/src/routes/root.tsx +++ b/spotizerr-ui/src/routes/root.tsx @@ -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 (
@@ -89,6 +91,7 @@ function AppLayout() {
+ Watchlist @@ -124,6 +127,7 @@ function AppLayout() { Spotizerr +
@@ -170,7 +174,9 @@ export default function Root() { return ( - + + + ); diff --git a/spotizerr-ui/src/types/auth.ts b/spotizerr-ui/src/types/auth.ts new file mode 100644 index 0000000..d6ced0d --- /dev/null +++ b/spotizerr-ui/src/types/auth.ts @@ -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; + register: (userData: RegisterRequest) => Promise; + logout: () => void; + checkAuthStatus: () => Promise; + + // Token management + getToken: () => string | null; + setToken: (token: string | null, rememberMe?: boolean) => void; + + // Session management + isRemembered: () => boolean; +} + +export interface AuthError { + message: string; + status?: number; +} \ No newline at end of file